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 = 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
144/// Render the full `README.md`: one section per schema, each with a field table
145/// plus a subsection per nested `definitions` entry it references.
146fn 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
184/// One more schema to render as its own subsection: either a named
185/// `definitions` entry (referenced via `$ref`), or an anonymous inline object
186/// schema (no `$ref` of its own — e.g. an array's `items`, embedded directly
187/// rather than hoisted to `definitions`, as `model_catalog_schema()` does for
188/// `ModelCatalogEntry` — see its doc comment for why). Anonymous sections are
189/// titled after the field that introduced them.
190struct PendingSection {
191    title: String,
192    schema: Value,
193}
194
195/// Render one schema's body: its own field table (if it's an object schema, or a
196/// "Variants" breakdown if it's a `oneOf`/`anyOf`), followed by a subsection per
197/// `definitions` entry and anonymous nested object it (transitively) references.
198/// `visited` dedupes sections reached from more than one place so each is only
199/// rendered once.
200fn 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
229/// Render a single schema value's field table or variant breakdown — no heading,
230/// callers add their own. Queues any anonymous inline nested object schema it
231/// finds (a property, or an array property's `items`, with its own `properties`
232/// but no `$ref`) onto `pending` for [`render_schema_body`] to render as its own
233/// subsection.
234fn 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
292/// If `field_schema` (or, for an array field, its `items`) is an anonymous
293/// inline object schema — has its own `properties` but isn't a `$ref`/`allOf`-
294/// wrapped reference to a named `definitions` entry — return a [`PendingSection`]
295/// for it, titled after `field_name` (or the schema's own `title`, if it has
296/// one). This is what documents e.g. `model_catalog_schema()`'s `models[]` items
297/// (`ModelCatalogEntry`, embedded inline rather than hoisted to `definitions` —
298/// see that function's doc comment for why), which a `$ref`-only walk would
299/// otherwise silently skip.
300fn 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
338/// A human label for one `oneOf`/`anyOf` variant: its serde-tag discriminant value
339/// if present (`properties.type.enum[0]`, the common internally-tagged-enum shape),
340/// its own `enum` value if it's a plain string-enum member, else its `title`, else
341/// a positional fallback.
342fn 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
373/// The description for a property schema, accounting for the `{"description":
374/// ..., "allOf": [{"$ref": ...}]}` wrapping `schemars` uses when a field has its
375/// own doc comment on top of a `$ref` (draft-07 doesn't allow sibling keywords
376/// next to `$ref` directly).
377fn 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
385/// A short type label for a field/variant schema: resolves `$ref`/`allOf`-wrapped
386/// refs to the referenced definition's name, arrays to `array of <item type>`,
387/// and falls back to the JSON Schema `type` keyword otherwise.
388fn 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
423/// The definition name a `#/definitions/<Name>` ref points at, formatted for
424/// display (backtick-quoted so it reads as a type name, not prose).
425fn 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
437/// GitHub-flavored-Markdown heading-anchor slug for the table of contents links
438/// (lowercase, spaces to hyphens, parens dropped).
439fn 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}