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 = 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 schema_for<T: schemars::JsonSchema>() -> Value {
147 let root_schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
148 serde_json::to_value(root_schema).expect("schemars Schema always serializes to JSON")
149}
150
151fn render_readme(schemas: &[ExportedSchema]) -> String {
154 let mut out = String::new();
155 out.push_str("# Schema reference\n\n");
156 out.push_str(
157 "Generated from this repo's JSON Schemas by `mecha10 schema export` (LAB-1829) — \
158 do not hand-edit. Regenerate with:\n\n```\ncargo run -p mecha10-cli --bin mecha10 -- schema export\n```\n\n\
159 Descriptions come from the schemas themselves (Rust doc comments on node config \
160 structs, via `schemars`, or `get_project_schema()`'s own `description` fields for \
161 the project config). See [`docs/validate-configs.md`](../validate-configs.md) for how \
162 these schemas are used to validate configs, and the raw `.schema.json` files \
163 alongside this doc for the exact JSON Schema.\n\n",
164 );
165
166 out.push_str("## Contents\n\n");
167 for exported in schemas {
168 let anchor = slugify(&exported.title);
169 let _ = writeln!(out, "- [{}](#{anchor})", exported.title);
170 }
171 out.push('\n');
172
173 for exported in schemas {
174 let _ = writeln!(out, "## {}\n", exported.title);
175 if let Some(description) = exported.schema.get("description").and_then(Value::as_str) {
176 let _ = writeln!(out, "{description}\n");
177 }
178 let _ = writeln!(
179 out,
180 "Raw schema: [`{}`]({}).\n",
181 exported.relative_json_path.display(),
182 exported.relative_json_path.display()
183 );
184
185 render_schema_body(&exported.schema, &exported.schema, &mut out, &mut BTreeSet::new());
186 }
187
188 out
189}
190
191struct PendingSection {
198 title: String,
199 schema: Value,
200}
201
202fn render_schema_body(root: &Value, schema: &Value, out: &mut String, visited: &mut BTreeSet<String>) {
208 let mut pending: Vec<PendingSection> = Vec::new();
209 render_value_body(schema, out, &mut pending);
210
211 if let Some(definitions) = root.get("definitions").and_then(Value::as_object) {
216 let mut names: Vec<&String> = definitions.keys().collect();
217 names.sort();
218 for name in names {
219 pending.push(PendingSection {
220 title: name.clone(),
221 schema: definitions[name].clone(),
222 });
223 }
224 }
225
226 let mut index = 0;
230 while index < pending.len() {
231 if visited.insert(pending[index].title.clone()) {
232 let _ = writeln!(out, "### {}\n", pending[index].title);
233 if let Some(description) = pending[index].schema.get("description").and_then(Value::as_str) {
234 let _ = writeln!(out, "{description}\n");
235 }
236 let schema_to_render = pending[index].schema.clone();
237 render_value_body(&schema_to_render, out, &mut pending);
238 }
239 index += 1;
240 }
241}
242
243fn render_value_body(schema: &Value, out: &mut String, pending: &mut Vec<PendingSection>) {
249 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
250 let required: BTreeSet<&str> = schema
251 .get("required")
252 .and_then(Value::as_array)
253 .map(|values| values.iter().filter_map(Value::as_str).collect())
254 .unwrap_or_default();
255
256 let mut names: Vec<&String> = properties.keys().collect();
257 names.sort();
258
259 out.push_str("| Field | Type | Required | Description |\n");
260 out.push_str("| --- | --- | --- | --- |\n");
261 for name in names {
262 let field_schema = &properties[name];
263 let type_label = type_label(field_schema);
264 let is_required = if required.contains(name.as_str()) { "yes" } else { "no" };
265 let description = field_description(field_schema).unwrap_or_default();
266 let _ = writeln!(out, "| `{name}` | {type_label} | {is_required} | {description} |");
267 if let Some(nested) = anonymous_nested_object(name, field_schema) {
268 pending.push(nested);
269 }
270 }
271 out.push('\n');
272 return;
273 }
274
275 if let Some(variants) = schema
276 .get("oneOf")
277 .or_else(|| schema.get("anyOf"))
278 .and_then(Value::as_array)
279 {
280 out.push_str("One of the following variants:\n\n");
281 for (index, variant) in variants.iter().enumerate() {
282 let label = variant_label(variant, index);
283 let _ = writeln!(out, "- **{label}**");
284 }
285 out.push('\n');
286 for (index, variant) in variants.iter().enumerate() {
287 let is_bare_reference_or_null = (variant.get("$ref").is_some()
295 || variant.get("type").and_then(Value::as_str) == Some("null"))
296 && variant.get("properties").is_none();
297 if is_bare_reference_or_null || (variant.get("enum").is_some() && variant.get("properties").is_none()) {
298 continue;
299 }
300 let label = variant_label(variant, index);
301 let _ = writeln!(out, "**{label}**\n");
302 render_value_body(variant, out, pending);
303 }
304 return;
305 }
306
307 if let Some(items) = schema.get("enum").and_then(Value::as_array) {
308 let values: Vec<String> = items.iter().map(display_scalar).collect();
309 let _ = writeln!(out, "Allowed values: {}\n", values.join(", "));
310 }
311}
312
313fn anonymous_nested_object(field_name: &str, field_schema: &Value) -> Option<PendingSection> {
322 let candidate = if field_schema.get("type").and_then(Value::as_str) == Some("array") {
323 field_schema.get("items")?
324 } else {
325 field_schema
326 };
327
328 if candidate.get("$ref").is_some() {
329 return None;
330 }
331 if let Some(all_of) = candidate.get("allOf").and_then(Value::as_array) {
332 if all_of.iter().any(|s| s.get("$ref").is_some()) {
333 return None;
334 }
335 }
336 if candidate.get("oneOf").is_some() || candidate.get("anyOf").is_some() {
337 return None;
338 }
339
340 let has_properties = candidate
341 .get("properties")
342 .and_then(Value::as_object)
343 .is_some_and(|props| !props.is_empty());
344 if !has_properties {
345 return None;
346 }
347
348 let title = candidate
349 .get("title")
350 .and_then(Value::as_str)
351 .map(str::to_string)
352 .unwrap_or_else(|| field_name.to_string());
353 Some(PendingSection {
354 title,
355 schema: candidate.clone(),
356 })
357}
358
359fn variant_label(variant: &Value, index: usize) -> String {
364 if let Some(tag) = variant
365 .get("properties")
366 .and_then(|p| p.get("type"))
367 .and_then(|t| t.get("enum"))
368 .and_then(Value::as_array)
369 .and_then(|values| values.first())
370 .and_then(Value::as_str)
371 {
372 return format!("`{tag}`");
373 }
374 if let Some(tag) = variant
375 .get("enum")
376 .and_then(Value::as_array)
377 .and_then(|values| values.first())
378 .and_then(Value::as_str)
379 {
380 return format!("`{tag}`");
381 }
382 if let Some(title) = variant.get("title").and_then(Value::as_str) {
383 return title.to_string();
384 }
385 if variant.get("type").and_then(Value::as_str) == Some("null") {
386 return "`null`".to_string();
387 }
388 if let Some(reference) = variant.get("$ref").and_then(Value::as_str) {
389 return ref_type_label(reference);
390 }
391 format!("variant {index}")
392}
393
394fn field_description(schema: &Value) -> Option<String> {
399 schema
400 .get("description")
401 .and_then(Value::as_str)
402 .map(|s| s.replace('\n', " ").trim().to_string())
403 .filter(|s| !s.is_empty())
404}
405
406fn type_label(schema: &Value) -> String {
410 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
411 return ref_type_label(reference);
412 }
413 if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) {
414 if let Some(reference) = all_of.iter().find_map(|s| s.get("$ref")).and_then(Value::as_str) {
415 return ref_type_label(reference);
416 }
417 }
418 if let Some(one_of) = schema
419 .get("oneOf")
420 .or_else(|| schema.get("anyOf"))
421 .and_then(Value::as_array)
422 {
423 let labels: Vec<String> = one_of.iter().map(type_label).collect();
424 return format!("one of: {}", labels.join(", "));
425 }
426 if schema.get("type").and_then(Value::as_str) == Some("array") {
427 let item_label = schema.get("items").map_or_else(|| "any".to_string(), type_label);
428 return format!("array of {item_label}");
429 }
430 if schema.get("properties").is_some() {
436 return schema
437 .get("title")
438 .and_then(Value::as_str)
439 .map_or_else(|| "object".to_string(), |title| format!("`{title}`"));
440 }
441 match schema.get("type") {
442 Some(Value::String(t)) => t.clone(),
443 Some(Value::Array(types)) => types.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" or "),
444 _ if schema.get("enum").is_some() => "enum".to_string(),
445 _ => "any".to_string(),
446 }
447}
448
449fn ref_type_label(reference: &str) -> String {
452 let name = reference.rsplit('/').next().unwrap_or(reference);
453 format!("`{name}`")
454}
455
456fn display_scalar(value: &Value) -> String {
457 match value {
458 Value::String(s) => format!("`{s}`"),
459 other => format!("`{other}`"),
460 }
461}
462
463fn slugify(title: &str) -> String {
466 title
467 .to_lowercase()
468 .chars()
469 .filter_map(|c| {
470 if c.is_alphanumeric() || c == ' ' || c == '-' {
471 Some(if c == ' ' { '-' } else { c })
472 } else {
473 None
474 }
475 })
476 .collect()
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use serde_json::json;
483 use tempfile::tempdir;
484
485 #[test]
486 fn type_label_resolves_plain_primitive() {
487 let schema = json!({"type": "string"});
488 assert_eq!(type_label(&schema), "string");
489 }
490
491 #[test]
492 fn type_label_resolves_direct_ref() {
493 let schema = json!({"$ref": "#/definitions/SourceConfig"});
494 assert_eq!(type_label(&schema), "`SourceConfig`");
495 }
496
497 #[test]
498 fn type_label_resolves_all_of_wrapped_ref() {
499 let schema = json!({
500 "description": "Frame source configuration",
501 "allOf": [{"$ref": "#/definitions/SourceConfig"}]
502 });
503 assert_eq!(type_label(&schema), "`SourceConfig`");
504 }
505
506 #[test]
507 fn type_label_resolves_array_of_ref() {
508 let schema = json!({"type": "array", "items": {"$ref": "#/definitions/ModelCatalogEntry"}});
509 assert_eq!(type_label(&schema), "array of `ModelCatalogEntry`");
510 }
511
512 #[test]
513 fn field_description_reads_plain_description() {
514 let schema = json!({"type": "string", "description": "Camera ID for identification"});
515 assert_eq!(
516 field_description(&schema).as_deref(),
517 Some("Camera ID for identification")
518 );
519 }
520
521 #[test]
522 fn field_description_reads_all_of_wrapped_description() {
523 let schema = json!({
524 "description": "Frame source configuration",
525 "allOf": [{"$ref": "#/definitions/SourceConfig"}]
526 });
527 assert_eq!(
528 field_description(&schema).as_deref(),
529 Some("Frame source configuration")
530 );
531 }
532
533 #[test]
534 fn render_value_body_produces_a_field_table_with_required_column() {
535 let root = json!({
536 "type": "object",
537 "required": ["name"],
538 "properties": {
539 "name": {"type": "string", "description": "The name"},
540 "note": {"type": "string", "description": "Optional note"}
541 }
542 });
543 let mut out = String::new();
544 render_value_body(&root, &mut out, &mut Vec::new());
545 assert!(out.contains("| `name` | string | yes | The name |"));
546 assert!(out.contains("| `note` | string | no | Optional note |"));
547 }
548
549 #[test]
550 fn render_value_body_queues_anonymous_nested_array_item_object() {
551 let root = json!({
552 "type": "object",
553 "properties": {
554 "models": {
555 "type": "array",
556 "items": {
557 "type": "object",
558 "properties": {"name": {"type": "string", "description": "Model name"}}
559 }
560 }
561 }
562 });
563 let mut out = String::new();
564 render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
565 assert!(out.contains("### models"));
566 assert!(out.contains("| `name` | string | no | Model name |"));
567 }
568
569 #[test]
570 fn render_schema_body_documents_referenced_definitions_once() {
571 let root = json!({
572 "type": "object",
573 "properties": {
574 "source": {"allOf": [{"$ref": "#/definitions/SourceConfig"}]}
575 },
576 "definitions": {
577 "SourceConfig": {
578 "type": "object",
579 "properties": {"path": {"type": "string"}}
580 }
581 }
582 });
583 let mut out = String::new();
584 render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
585 assert_eq!(out.matches("### SourceConfig").count(), 1);
586 assert!(out.contains("| `path` | string | no |"));
587 }
588
589 #[test]
590 fn project_schema_behaviors_property_reflects_the_real_behaviors_config_struct() {
591 let schema = project_schema_with_introspected_behaviors();
599 let behaviors = &schema["properties"]["behaviors"];
600
601 assert_eq!(behaviors["title"], "BehaviorsConfig");
602 assert!(
603 behaviors["properties"].get("active").is_some(),
604 "expected `behaviors.properties.active`, got: {behaviors}"
605 );
606 assert!(
607 behaviors["properties"].get("default").is_none(),
608 "`behaviors` schema still has a stale `default` field: {behaviors}"
609 );
610 let required: Vec<&str> = behaviors["required"]
611 .as_array()
612 .expect("BehaviorsConfig schema should have a `required` array")
613 .iter()
614 .filter_map(Value::as_str)
615 .collect();
616 assert_eq!(required, vec!["active"]);
617 }
618
619 #[test]
620 fn variant_label_prefers_serde_tag_discriminant() {
621 let variant = json!({
622 "type": "object",
623 "properties": {"type": {"enum": ["mock"]}}
624 });
625 assert_eq!(variant_label(&variant, 0), "`mock`");
626 }
627
628 #[test]
629 fn export_schemas_writes_json_and_readme() {
630 let dir = tempdir().unwrap();
631 let written = export_schemas(dir.path()).unwrap();
632
633 assert!(written.contains(&PathBuf::from("mecha10.schema.json")));
634 assert!(written.contains(&PathBuf::from("model-catalog.schema.json")));
635 assert!(written.contains(&PathBuf::from("nodes/camera.schema.json")));
636 assert!(written.contains(&PathBuf::from("README.md")));
637
638 assert!(dir.path().join("mecha10.schema.json").exists());
639 assert!(dir.path().join("nodes/camera.schema.json").exists());
640 assert!(dir.path().join("model-catalog.schema.json").exists());
641
642 let readme = fs::read_to_string(dir.path().join("README.md")).unwrap();
643 assert!(readme.contains("# Schema reference"));
644 assert!(readme.contains("## Project config (mecha10.json)"));
645 assert!(readme.contains("## Node config: camera"));
646 assert!(readme.contains("## Model catalog (model_catalog.toml)"));
647 assert!(readme.contains("### ModelCatalogEntry"));
653 assert!(readme.contains("| `name` |"));
654 }
655
656 #[test]
657 fn export_schemas_covers_every_builtin_node_type() {
658 let dir = tempdir().unwrap();
659 export_schemas(dir.path()).unwrap();
660 for node_type in mecha10_schema_registry::BUILTIN_NODE_TYPES.iter() {
661 let path = dir.path().join("nodes").join(format!("{node_type}.schema.json"));
662 assert!(
663 path.exists(),
664 "expected schema file for '{node_type}' at {}",
665 path.display()
666 );
667 }
668 }
669
670 #[test]
671 fn export_schemas_output_is_deterministic() {
672 let dir_a = tempdir().unwrap();
673 let dir_b = tempdir().unwrap();
674 export_schemas(dir_a.path()).unwrap();
675 export_schemas(dir_b.path()).unwrap();
676
677 let readme_a = fs::read_to_string(dir_a.path().join("README.md")).unwrap();
678 let readme_b = fs::read_to_string(dir_b.path().join("README.md")).unwrap();
679 assert_eq!(readme_a, readme_b);
680
681 let schema_a = fs::read_to_string(dir_a.path().join("mecha10.schema.json")).unwrap();
682 let schema_b = fs::read_to_string(dir_b.path().join("mecha10.schema.json")).unwrap();
683 assert_eq!(schema_a, schema_b);
684 }
685}