Skip to main content

aion_package/codegen/
schema_emit.rs

1//! Emission of the generated `schemas/*.json` artifacts.
2//!
3//! Types-first (ADR-014): `schemas/*.json` is a GENERATED artifact — emitted
4//! from the boundary types the author declared in `src/<pkg>_io.gleam`, never
5//! authored. The emitted documents keep serving their real consumers
6//! untouched: packaging validation (`workflow.toml` `input_schema` /
7//! `output_schema`), `aion input` skeletons, and external reference (agents,
8//! HTTP clients, docs).
9//!
10//! One document is emitted per boundary type, at `schemas/<stem>.json`, with
11//! references to sibling types inlined structurally (the front-end rejects
12//! reference cycles, so inlining terminates). Every emitted document carries a
13//! `$comment` marker naming its origin; the marker is how this module tells
14//! its own artifacts from hand-authored strays:
15//!
16//! - a marked file no longer backed by a type is STALE: removed on a write
17//!   run, a loud drift error under `--check`;
18//! - an unmarked `*.json` is a STRAY (a pre-types-first authored schema or a
19//!   hand-written file): always a loud error carrying the migration hint,
20//!   because schema-first authoring is gone and two sources of truth is the
21//!   exact disease ADR-014 kills.
22//!
23//! Rendering is by hand (never a `serde_json` map) so the bytes are fully
24//! deterministic: same model in, byte-identical documents out.
25
26use std::fmt::Write as _;
27use std::io;
28use std::path::{Path, PathBuf};
29
30use super::error::CodegenError;
31use super::model::{BoundaryType, EnumDef, GleamType, RecordDef, TypeDef};
32use super::project::{CodegenMode, check_on_disk};
33
34/// Directory (relative to the project root) the schema artifacts are emitted
35/// into.
36const SCHEMAS_DIR: &str = "schemas";
37
38/// The marker prefix every emitted schema carries in its `$comment`. Detection
39/// checks for this prefix, so the package-specific suffix can name the types
40/// module.
41const MARKER_PREFIX: &str = "Generated by aion generate from src/";
42
43/// The result of emitting (or checking) the schema artifacts.
44#[derive(Debug)]
45pub struct SchemaEmitReport {
46    /// Every emitted schema path, relative to the project root, in type-name
47    /// order.
48    pub emitted: Vec<String>,
49    /// Stale generated schemas removed by a write run (empty under `--check`).
50    pub removed: Vec<String>,
51    /// Whether the artifacts were written (`false` in check mode).
52    pub written: bool,
53}
54
55/// Emits (or, in [`CodegenMode::Check`], verifies) `schemas/<stem>.json` for
56/// every boundary type.
57///
58/// # Errors
59///
60/// Returns a [`CodegenError`] for a write/read failure, a stray unmarked
61/// `*.json` in `schemas/`, and — in check mode — a missing, drifted, or stale
62/// document.
63pub fn emit_schemas(
64    root: &Path,
65    package_name: &str,
66    types: &[BoundaryType],
67    mode: CodegenMode,
68) -> Result<SchemaEmitReport, CodegenError> {
69    let schemas_dir = root.join(SCHEMAS_DIR);
70    let mut emitted = Vec::with_capacity(types.len());
71    let mut rendered: Vec<(PathBuf, String)> = Vec::with_capacity(types.len());
72    for boundary in types {
73        let contents = render_schema(package_name, boundary);
74        emitted.push(boundary.file.display().to_string());
75        rendered.push((root.join(&boundary.file), contents));
76    }
77
78    let written = match mode {
79        CodegenMode::Write => {
80            std::fs::create_dir_all(&schemas_dir).map_err(|source| CodegenError::Write {
81                path: schemas_dir.clone(),
82                source,
83            })?;
84            for (path, contents) in &rendered {
85                std::fs::write(path, contents).map_err(|source| CodegenError::Write {
86                    path: path.clone(),
87                    source,
88                })?;
89            }
90            true
91        }
92        CodegenMode::Check => {
93            for (path, contents) in &rendered {
94                check_on_disk(path, contents)?;
95            }
96            false
97        }
98    };
99
100    let removed = reconcile_directory(&schemas_dir, &emitted, mode)?;
101    Ok(SchemaEmitReport {
102        emitted,
103        removed,
104        written,
105    })
106}
107
108/// Walks `schemas/*.json` after emission and classifies every file that is not
109/// an emitted artifact: marked files are stale generated output (removed on
110/// write, a drift error under check); unmarked files are strays and always a
111/// loud error with the migration hint.
112fn reconcile_directory(
113    schemas_dir: &Path,
114    emitted: &[String],
115    mode: CodegenMode,
116) -> Result<Vec<String>, CodegenError> {
117    let entries = match std::fs::read_dir(schemas_dir) {
118        Ok(entries) => entries,
119        // Check mode on a project that never emitted: the per-file
120        // `check_on_disk` above already reported the missing artifacts.
121        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
122        Err(source) => {
123            return Err(CodegenError::SchemasDirRead {
124                path: schemas_dir.to_path_buf(),
125                source,
126            });
127        }
128    };
129    let mut removed = Vec::new();
130    let mut names: Vec<PathBuf> = Vec::new();
131    for entry in entries {
132        let entry = entry.map_err(|source| CodegenError::SchemasDirRead {
133            path: schemas_dir.to_path_buf(),
134            source,
135        })?;
136        let path = entry.path();
137        if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
138            names.push(path);
139        }
140    }
141    names.sort();
142
143    for path in names {
144        let file_name = path
145            .file_name()
146            .map(|name| name.to_string_lossy().into_owned())
147            .unwrap_or_default();
148        let relative = format!("{SCHEMAS_DIR}/{file_name}");
149        if emitted.contains(&relative) {
150            continue;
151        }
152        let contents =
153            std::fs::read_to_string(&path).map_err(|source| CodegenError::CheckRead {
154                path: path.clone(),
155                source,
156            })?;
157        if !contents.contains(MARKER_PREFIX) {
158            return Err(CodegenError::SchemaStray { path });
159        }
160        match mode {
161            CodegenMode::Write => {
162                std::fs::remove_file(&path).map_err(|source| CodegenError::Write {
163                    path: path.clone(),
164                    source,
165                })?;
166                removed.push(relative);
167            }
168            CodegenMode::Check => {
169                return Err(CodegenError::CheckDrift { path });
170            }
171        }
172    }
173    Ok(removed)
174}
175
176/// Renders one boundary type's schema document, byte-deterministically.
177fn render_schema(package_name: &str, boundary: &BoundaryType) -> String {
178    let mut out = String::new();
179    out.push_str("{\n");
180    let _ = writeln!(
181        out,
182        "  \"$comment\": \"{MARKER_PREFIX}{package_name}_io.gleam — do not edit; run `aion generate`.\","
183    );
184    render_body(&mut out, boundary);
185    out.push_str("}\n");
186    out
187}
188
189/// Renders one boundary type's schema document **without** the `$comment`
190/// origin marker, for a worker's advertised typed action surface.
191///
192/// The marker is deliberately absent, not merely tidied away: structure is the
193/// whole advertisement, and a do-not-edit note naming a Gleam source file is
194/// provenance prose that belongs only in the on-disk `schemas/*.json`.
195///
196/// This split originally existed because `$comment` decided admission —
197/// `normalize_schema` did not strip it, and the subset check demands that an
198/// unrecognised key on one side appear identically on the other, so advertising
199/// the marker made the schema unsatisfiable against any contract lacking the
200/// byte-identical text. That defect is fixed at the normalizer, which now treats
201/// `$comment` as the non-validating annotation the specification says it is. So
202/// keeping the marker out of the advertisement is hygiene now rather than a
203/// workaround — still right, but no longer load-bearing for admission.
204pub(super) fn render_advertised_schema(boundary: &BoundaryType) -> String {
205    let mut out = String::new();
206    out.push_str("{\n");
207    render_body(&mut out, boundary);
208    out.push_str("}\n");
209    out
210}
211
212/// Renders a boundary type's members (the surrounding braces are the
213/// caller's), shared by the on-disk artifact and the advertised surface so the
214/// two can never describe different shapes.
215fn render_body(out: &mut String, boundary: &BoundaryType) {
216    match boundary.defs.first() {
217        Some(TypeDef::Record(record)) => render_record_body(out, record, &boundary.defs, 1),
218        Some(TypeDef::Enum(definition)) => render_enum_body(out, definition, 1),
219        None => {}
220    }
221}
222
223/// Renders a record's `type`/`required`/`additionalProperties`/`properties`
224/// members at `indent` levels (the surrounding braces are the caller's).
225fn render_record_body(out: &mut String, record: &RecordDef, defs: &[TypeDef], indent: usize) {
226    let pad = "  ".repeat(indent);
227    let _ = writeln!(out, "{pad}\"type\": \"object\",");
228    let required: Vec<String> = record
229        .fields
230        .iter()
231        .filter(|field| field.required)
232        .map(|field| format!("\"{}\"", field.wire))
233        .collect();
234    let _ = writeln!(out, "{pad}\"required\": [{}],", required.join(", "));
235    let _ = writeln!(out, "{pad}\"additionalProperties\": false,");
236    if record.fields.is_empty() {
237        let _ = writeln!(out, "{pad}\"properties\": {{}}");
238        return;
239    }
240    let _ = writeln!(out, "{pad}\"properties\": {{");
241    for (position, field) in record.fields.iter().enumerate() {
242        let comma = if position + 1 == record.fields.len() {
243            ""
244        } else {
245            ","
246        };
247        let value = render_type(&field.ty, defs, indent + 1);
248        let _ = writeln!(out, "{pad}  \"{}\": {value}{comma}", field.wire);
249    }
250    let _ = writeln!(out, "{pad}}}");
251}
252
253/// Renders an enum's `type`/`enum` members at `indent` levels.
254fn render_enum_body(out: &mut String, definition: &EnumDef, indent: usize) {
255    let pad = "  ".repeat(indent);
256    let _ = writeln!(out, "{pad}\"type\": \"string\",");
257    let _ = writeln!(out, "{pad}\"enum\": [{}]", enum_values(definition));
258}
259
260/// The comma-joined quoted wire values of an enum.
261fn enum_values(definition: &EnumDef) -> String {
262    definition
263        .variants
264        .iter()
265        .map(|variant| format!("\"{}\"", variant.wire))
266        .collect::<Vec<_>>()
267        .join(", ")
268}
269
270/// Renders a field type's schema value. Scalars, enums, and lists of them stay
271/// on one line; object schemas (sibling record references) expand over
272/// multiple lines with their continuation at `indent` levels.
273fn render_type(ty: &GleamType, defs: &[TypeDef], indent: usize) -> String {
274    match ty {
275        GleamType::String => "{ \"type\": \"string\" }".to_owned(),
276        GleamType::Int => "{ \"type\": \"integer\" }".to_owned(),
277        GleamType::Float => "{ \"type\": \"number\" }".to_owned(),
278        GleamType::Bool => "{ \"type\": \"boolean\" }".to_owned(),
279        GleamType::List(inner) => format!(
280            "{{ \"type\": \"array\", \"items\": {} }}",
281            render_type(inner, defs, indent)
282        ),
283        GleamType::Named { type_name, .. } => {
284            match defs.iter().find(|def| def.type_name() == type_name) {
285                Some(TypeDef::Enum(definition)) => format!(
286                    "{{ \"type\": \"string\", \"enum\": [{}] }}",
287                    enum_values(definition)
288                ),
289                Some(TypeDef::Record(record)) => {
290                    let pad = "  ".repeat(indent);
291                    let mut nested = String::from("{\n");
292                    render_record_body(&mut nested, record, defs, indent + 1);
293                    let _ = write!(nested, "{pad}}}");
294                    nested
295                }
296                // The front-end guarantees every named reference resolves in
297                // the closure; an unresolvable one would already have failed
298                // there, so render nothing rather than panic.
299                None => "{ }".to_owned(),
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use std::path::PathBuf;
308
309    use super::{emit_schemas, render_schema};
310    use crate::codegen::error::CodegenError;
311    use crate::codegen::model::{
312        BoundaryType, EnumDef, EnumVariant, Field, GleamType, RecordDef, TypeDef,
313    };
314    use crate::codegen::project::CodegenMode;
315    use crate::project::fixture;
316
317    type TestResult = Result<(), Box<dyn std::error::Error>>;
318
319    fn named(type_name: &str) -> GleamType {
320        GleamType::Named {
321            type_name: type_name.to_owned(),
322            fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
323        }
324    }
325
326    fn field(wire: &str, ty: GleamType, required: bool) -> Field {
327        Field {
328            wire: wire.to_owned(),
329            ty,
330            required,
331        }
332    }
333
334    fn record_def(type_name: &str, fields: Vec<Field>) -> TypeDef {
335        TypeDef::Record(RecordDef {
336            type_name: type_name.to_owned(),
337            fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
338            fields,
339        })
340    }
341
342    fn boundary(type_name: &str, defs: Vec<TypeDef>) -> BoundaryType {
343        let stem = crate::codegen::names::pascal_to_snake(type_name);
344        BoundaryType {
345            file: PathBuf::from(format!("schemas/{stem}.json")),
346            stem,
347            root: named(type_name),
348            defs,
349        }
350    }
351
352    fn kind_enum() -> TypeDef {
353        TypeDef::Enum(EnumDef {
354            type_name: "OrderKind".to_owned(),
355            fn_prefix: "order_kind".to_owned(),
356            variants: vec![
357                EnumVariant {
358                    constructor: "OrderKindStandard".to_owned(),
359                    wire: "standard".to_owned(),
360                },
361                EnumVariant {
362                    constructor: "OrderKindRush".to_owned(),
363                    wire: "rush".to_owned(),
364                },
365            ],
366        })
367    }
368
369    #[test]
370    fn record_schema_renders_required_optional_list_enum_and_nested() {
371        let order = boundary(
372            "OrderInput",
373            vec![
374                record_def(
375                    "OrderInput",
376                    vec![
377                        field("order_id", GleamType::String, true),
378                        field("quantity", GleamType::Int, true),
379                        field("ratio", GleamType::Float, true),
380                        field("rush", GleamType::Bool, true),
381                        field("tags", GleamType::List(Box::new(GleamType::String)), true),
382                        field("kind", named("OrderKind"), true),
383                        field("line", named("OrderLine"), true),
384                        field("note", GleamType::String, false),
385                    ],
386                ),
387                kind_enum(),
388                record_def("OrderLine", vec![field("sku", GleamType::String, true)]),
389            ],
390        );
391
392        let expected = r#"{
393  "$comment": "Generated by aion generate from src/demo_io.gleam — do not edit; run `aion generate`.",
394  "type": "object",
395  "required": ["order_id", "quantity", "ratio", "rush", "tags", "kind", "line"],
396  "additionalProperties": false,
397  "properties": {
398    "order_id": { "type": "string" },
399    "quantity": { "type": "integer" },
400    "ratio": { "type": "number" },
401    "rush": { "type": "boolean" },
402    "tags": { "type": "array", "items": { "type": "string" } },
403    "kind": { "type": "string", "enum": ["standard", "rush"] },
404    "line": {
405      "type": "object",
406      "required": ["sku"],
407      "additionalProperties": false,
408      "properties": {
409        "sku": { "type": "string" }
410      }
411    },
412    "note": { "type": "string" }
413  }
414}
415"#;
416        assert_eq!(render_schema("demo", &order), expected);
417    }
418
419    #[test]
420    fn enum_schema_and_empty_record_render() {
421        let kind = boundary("OrderKind", vec![kind_enum()]);
422        assert_eq!(
423            render_schema("demo", &kind),
424            "{\n  \"$comment\": \"Generated by aion generate from src/demo_io.gleam — do not \
425             edit; run `aion generate`.\",\n  \"type\": \"string\",\n  \"enum\": [\"standard\", \
426             \"rush\"]\n}\n"
427        );
428
429        let blank = boundary("Blank", vec![record_def("Blank", Vec::new())]);
430        let rendered = render_schema("demo", &blank);
431        assert!(rendered.contains("\"required\": [],"));
432        assert!(rendered.contains("\"properties\": {}"));
433    }
434
435    #[test]
436    fn emitted_schemas_parse_as_json_and_are_deterministic() -> TestResult {
437        let order = boundary(
438            "OrderInput",
439            vec![record_def(
440                "OrderInput",
441                vec![
442                    field("order_id", GleamType::String, true),
443                    field("note", GleamType::String, false),
444                ],
445            )],
446        );
447        let first = render_schema("demo", &order);
448        let second = render_schema("demo", &order);
449        assert_eq!(first, second, "rendering must be deterministic");
450        let parsed: serde_json::Value = serde_json::from_str(&first)?;
451        assert_eq!(parsed["type"], "object");
452        assert_eq!(parsed["required"], serde_json::json!(["order_id"]));
453        assert_eq!(parsed["additionalProperties"], false);
454        assert_eq!(parsed["properties"]["note"]["type"], "string");
455        Ok(())
456    }
457
458    #[test]
459    fn write_then_check_round_trips_and_check_catches_a_hand_edit() -> TestResult {
460        let root = fixture::temp_project("schema-emit-roundtrip", &[])?;
461        let types = [boundary(
462            "OrderInput",
463            vec![record_def(
464                "OrderInput",
465                vec![field("order_id", GleamType::String, true)],
466            )],
467        )];
468
469        let report = emit_schemas(&root, "demo", &types, CodegenMode::Write)?;
470        assert!(report.written);
471        assert_eq!(report.emitted, vec!["schemas/order_input.json".to_owned()]);
472        assert!(root.join("schemas/order_input.json").is_file());
473
474        let checked = emit_schemas(&root, "demo", &types, CodegenMode::Check)?;
475        assert!(!checked.written);
476
477        let path = root.join("schemas/order_input.json");
478        let mut tampered = std::fs::read_to_string(&path)?;
479        tampered.push('\n');
480        std::fs::write(&path, &tampered)?;
481        let result = emit_schemas(&root, "demo", &types, CodegenMode::Check);
482        let Err(CodegenError::CheckDrift { path: drifted }) = result else {
483            std::fs::remove_dir_all(&root)?;
484            return Err(format!("expected CheckDrift, got {result:?}").into());
485        };
486        assert_eq!(drifted, path);
487        std::fs::remove_dir_all(&root)?;
488        Ok(())
489    }
490
491    #[test]
492    fn stray_unmarked_json_is_a_loud_error_with_the_migration_hint() -> TestResult {
493        let root = fixture::temp_project(
494            "schema-emit-stray",
495            &[("schemas/legacy.json", b"{ \"type\": \"object\" }" as &[u8])],
496        )?;
497        let types = [boundary(
498            "OrderInput",
499            vec![record_def(
500                "OrderInput",
501                vec![field("order_id", GleamType::String, true)],
502            )],
503        )];
504
505        let result = emit_schemas(&root, "demo", &types, CodegenMode::Write);
506        let Err(CodegenError::SchemaStray { path }) = result else {
507            std::fs::remove_dir_all(&root)?;
508            return Err(format!("expected SchemaStray, got {result:?}").into());
509        };
510        assert_eq!(path, root.join("schemas/legacy.json"));
511        let message = CodegenError::SchemaStray { path }.to_string();
512        assert!(
513            message.contains("src/<package>_io.gleam") && message.contains("aion generate"),
514            "the stray error must carry the migration hint: {message}"
515        );
516        std::fs::remove_dir_all(&root)?;
517        Ok(())
518    }
519
520    #[test]
521    fn stale_marked_schema_is_removed_on_write_and_drift_under_check() -> TestResult {
522        let root = fixture::temp_project("schema-emit-stale", &[])?;
523        let both = [
524            boundary("NewType", vec![record_def("NewType", Vec::new())]),
525            boundary("OldType", vec![record_def("OldType", Vec::new())]),
526        ];
527        emit_schemas(&root, "demo", &both, CodegenMode::Write)?;
528        assert!(root.join("schemas/old_type.json").is_file());
529
530        // The model moves on: OldType is renamed away, so its emitted schema
531        // is now a stale generated artifact.
532        let new = [boundary("NewType", vec![record_def("NewType", Vec::new())])];
533        // Under --check the stale marked file is drift.
534        let result = emit_schemas(&root, "demo", &new, CodegenMode::Check);
535        assert!(
536            matches!(result, Err(CodegenError::CheckDrift { ref path }) if path.ends_with("old_type.json")),
537            "stale marked schema must be drift under check: {result:?}"
538        );
539        // A write run removes it.
540        let report = emit_schemas(&root, "demo", &new, CodegenMode::Write)?;
541        assert_eq!(report.removed, vec!["schemas/old_type.json".to_owned()]);
542        assert!(!root.join("schemas/old_type.json").exists());
543        assert!(root.join("schemas/new_type.json").is_file());
544        std::fs::remove_dir_all(&root)?;
545        Ok(())
546    }
547}