Skip to main content

mecha10_cli/services/
schema_export.rs

1//! Generates `docs/schemas/` (LAB-1829) from this repo's real schemas: the
2//! hand-written project-config schema (`mecha10_core::schema_validation::get_project_schema`,
3//! with its `behaviors` property overridden by the real `schemars`-derived
4//! `BehaviorsConfig` schema -- see [`project_schema_with_introspected_behaviors`])
5//! and the `schemars`-derived per-node-type/model-catalog schemas
6//! (`mecha10_schema_registry`). See `docs/validate-configs.md` for how these schemas
7//! are used to validate configs; this module is purely about rendering them as
8//! reference docs — it adds no new schema content and hand-writes no field
9//! descriptions (those come from the schemas themselves, which pull node-config
10//! field docs from `packages/nodes/*/src/config.rs`'s doc comments via `schemars`).
11//!
12//! Every file this writes is generated output: the raw schema JSON (one file per
13//! schema) plus a single Markdown field reference. `scripts/ci/schema-docs-check.sh`
14//! re-runs this and diffs against what's committed, so `docs/schemas/` can never
15//! silently drift from the schemas it documents.
16
17use 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
24/// One schema this service exports: a relative path (under the export root) for
25/// its raw JSON, a title used in the generated Markdown, and the schema itself.
26struct ExportedSchema {
27    /// Path relative to the export root, e.g. `"mecha10.schema.json"` or
28    /// `"nodes/camera.schema.json"`.
29    relative_json_path: PathBuf,
30    /// Heading used for this schema's section in the generated Markdown.
31    title: String,
32    schema: Value,
33}
34
35/// Generates `docs/schemas/`-style reference docs (raw JSON Schema files plus a
36/// Markdown field reference) from every schema this CLI knows about. See the
37/// module-level docs for what "knows about" means and why nothing here
38/// hand-writes schema content.
39pub struct SchemaExportService;
40
41impl SchemaExportService {
42    /// Generate every schema this CLI knows about and write it under `out_dir`:
43    /// raw JSON Schema files plus a single `README.md` field reference. Returns
44    /// the list of files written (relative to `out_dir`), for callers that want
45    /// to report what changed.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if `out_dir` can't be created, or a file can't be written.
50    pub fn export(out_dir: &Path) -> Result<Vec<PathBuf>> {
51        export_schemas(out_dir)
52    }
53}
54
55/// Generate every schema this CLI knows about and write it under `out_dir`:
56/// raw JSON Schema files plus a single `README.md` field reference. Returns the
57/// list of files written (relative to `out_dir`), for callers that want to report
58/// what changed.
59///
60/// # Errors
61///
62/// Returns an error if `out_dir` can't be created, or a file can't be written.
63fn 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
86/// Every schema `mecha10 schema export` knows about, in the order they should
87/// appear in the generated Markdown.
88fn 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
115/// `mecha10_core::schema_validation::get_project_schema()`'s `behaviors` property
116/// is a hand-written JSON Schema fragment, not derived from
117/// `mecha10_cli_core::types::project::BehaviorsConfig` -- `mecha10-core` can't
118/// depend on `mecha10-cli-core` (the reverse dependency already exists, see
119/// `mecha10-cli-core`'s own doc comments), so the real struct isn't reachable
120/// from where that schema is built, unlike node configs (see
121/// `mecha10-schema-registry`, which lives downstream of every node crate for the
122/// same reason).
123///
124/// That hand-written fragment silently drifted from the real struct for its
125/// entire existence (it said `default` where the struct has always had
126/// `active`) and survived two prior `docs/schemas/` regenerations (LAB-1829)
127/// before an unrelated PR happened to fix its text. This crate *can* depend on
128/// `mecha10-cli-core` (it already does, for other CLI functionality), so this
129/// splices the real `schemars`-derived schema for `BehaviorsConfig` into the
130/// `behaviors` property here -- after this point, `docs/schemas/` reflects the
131/// real struct and can't silently drift from it again, even though the
132/// hand-written fragment in `mecha10-core` (used for runtime validation, where
133/// the dependency direction can't be worked around the same way) remains as a
134/// separately-maintained copy.
135fn 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
144/// The `schemars`-derived JSON Schema for `T`, as a `serde_json::Value` (the
145/// shape [`ExportedSchema`] and every render helper in this module works with).
146fn 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
151/// Render the full `README.md`: one section per schema, each with a field table
152/// plus a subsection per nested `definitions` entry it references.
153fn 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
191/// One more schema to render as its own subsection: either a named
192/// `definitions` entry (referenced via `$ref`), or an anonymous inline object
193/// schema (no `$ref` of its own — e.g. an array's `items`, embedded directly
194/// rather than hoisted to `definitions`, as `model_catalog_schema()` does for
195/// `ModelCatalogEntry` — see its doc comment for why). Anonymous sections are
196/// titled after the field that introduced them.
197struct PendingSection {
198    title: String,
199    schema: Value,
200}
201
202/// Render one schema's body: its own field table (if it's an object schema, or a
203/// "Variants" breakdown if it's a `oneOf`/`anyOf`), followed by a subsection per
204/// `definitions` entry and anonymous nested object it (transitively) references.
205/// `visited` dedupes sections reached from more than one place so each is only
206/// rendered once.
207fn 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    // Named `definitions` are collected up front (rather than only as
212    // encountered while walking properties) so every one gets documented even
213    // if it's only reachable indirectly (e.g. referenced solely from another
214    // definition), and in a stable (sorted) order.
215    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    // Anonymous nested objects are only discovered by walking properties (they
227    // have no root-level registry to enumerate up front), so this drains
228    // breadth-first, including sections newly queued while rendering others.
229    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
243/// Render a single schema value's field table or variant breakdown — no heading,
244/// callers add their own. Queues any anonymous inline nested object schema it
245/// finds (a property, or an array property's `items`, with its own `properties`
246/// but no `$ref`) onto `pending` for [`render_schema_body`] to render as its own
247/// subsection.
248fn 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            // A variant that's just `{"enum": [...]}` (a plain string-enum
288            // member, e.g. one member of a C-like enum split across `oneOf`
289            // by schemars so each member keeps its own doc comment) or a bare
290            // `{"$ref": ...}`/`{"type": "null"}` (an `Option<T>` represented
291            // as `anyOf: [T, null]`) is already fully captured by its bullet
292            // above -- the referenced type gets its own section anyway, and
293            // there's no additional body to render.
294            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
313/// If `field_schema` (or, for an array field, its `items`) is an anonymous
314/// inline object schema — has its own `properties` but isn't a `$ref`/`allOf`-
315/// wrapped reference to a named `definitions` entry — return a [`PendingSection`]
316/// for it, titled after `field_name` (or the schema's own `title`, if it has
317/// one). This is what documents e.g. `model_catalog_schema()`'s `models[]` items
318/// (`ModelCatalogEntry`, embedded inline rather than hoisted to `definitions` —
319/// see that function's doc comment for why), which a `$ref`-only walk would
320/// otherwise silently skip.
321fn 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
359/// A human label for one `oneOf`/`anyOf` variant: its serde-tag discriminant value
360/// if present (`properties.type.enum[0]`, the common internally-tagged-enum shape),
361/// its own `enum` value if it's a plain string-enum member, else its `title`, else
362/// a positional fallback.
363fn 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
394/// The description for a property schema, accounting for the `{"description":
395/// ..., "allOf": [{"$ref": ...}]}` wrapping `schemars` uses when a field has its
396/// own doc comment on top of a `$ref` (draft-07 doesn't allow sibling keywords
397/// next to `$ref` directly).
398fn 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
406/// A short type label for a field/variant schema: resolves `$ref`/`allOf`-wrapped
407/// refs to the referenced definition's name, arrays to `array of <item type>`,
408/// and falls back to the JSON Schema `type` keyword otherwise.
409fn 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    // An anonymous inline object (no `$ref`) still has its own subsection
431    // rendered by `anonymous_nested_object`/`render_schema_body`, titled after
432    // its own `title` if it has one — match that here (ahead of the generic
433    // `type` match below, which would otherwise just say "object") so the type
434    // column points a reader at the right heading.
435    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
449/// The definition name a `#/definitions/<Name>` ref points at, formatted for
450/// display (backtick-quoted so it reads as a type name, not prose).
451fn 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
463/// GitHub-flavored-Markdown heading-anchor slug for the table of contents links
464/// (lowercase, spaces to hyphens, parens dropped).
465fn 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        // Regression guard (see `project_schema_with_introspected_behaviors`'s doc
592        // comment): `get_project_schema()`'s hand-written `behaviors` fragment said
593        // `default` where the real `BehaviorsConfig` struct has always had `active`,
594        // and that wrong field name survived two prior `docs/schemas/` regenerations
595        // because nothing tied the hand-written fragment to the real struct. This
596        // pins the exported schema's `behaviors` property to the real struct's shape
597        // so it can't drift like that again.
598        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        // The model catalog embeds `ModelCatalogEntry` inline (not via a named
648        // `definitions` entry — see `model_catalog_schema()`'s doc comment), so
649        // this also guards that its fields still surface as their own
650        // subsection (titled after the struct's own schemars-generated
651        // `title`) rather than being silently skipped.
652        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}