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    match boundary.defs.first() {
185        Some(TypeDef::Record(record)) => render_record_body(&mut out, record, &boundary.defs, 1),
186        Some(TypeDef::Enum(definition)) => render_enum_body(&mut out, definition, 1),
187        None => {}
188    }
189    out.push_str("}\n");
190    out
191}
192
193/// Renders a record's `type`/`required`/`additionalProperties`/`properties`
194/// members at `indent` levels (the surrounding braces are the caller's).
195fn render_record_body(out: &mut String, record: &RecordDef, defs: &[TypeDef], indent: usize) {
196    let pad = "  ".repeat(indent);
197    let _ = writeln!(out, "{pad}\"type\": \"object\",");
198    let required: Vec<String> = record
199        .fields
200        .iter()
201        .filter(|field| field.required)
202        .map(|field| format!("\"{}\"", field.wire))
203        .collect();
204    let _ = writeln!(out, "{pad}\"required\": [{}],", required.join(", "));
205    let _ = writeln!(out, "{pad}\"additionalProperties\": false,");
206    if record.fields.is_empty() {
207        let _ = writeln!(out, "{pad}\"properties\": {{}}");
208        return;
209    }
210    let _ = writeln!(out, "{pad}\"properties\": {{");
211    for (position, field) in record.fields.iter().enumerate() {
212        let comma = if position + 1 == record.fields.len() {
213            ""
214        } else {
215            ","
216        };
217        let value = render_type(&field.ty, defs, indent + 1);
218        let _ = writeln!(out, "{pad}  \"{}\": {value}{comma}", field.wire);
219    }
220    let _ = writeln!(out, "{pad}}}");
221}
222
223/// Renders an enum's `type`/`enum` members at `indent` levels.
224fn render_enum_body(out: &mut String, definition: &EnumDef, indent: usize) {
225    let pad = "  ".repeat(indent);
226    let _ = writeln!(out, "{pad}\"type\": \"string\",");
227    let _ = writeln!(out, "{pad}\"enum\": [{}]", enum_values(definition));
228}
229
230/// The comma-joined quoted wire values of an enum.
231fn enum_values(definition: &EnumDef) -> String {
232    definition
233        .variants
234        .iter()
235        .map(|variant| format!("\"{}\"", variant.wire))
236        .collect::<Vec<_>>()
237        .join(", ")
238}
239
240/// Renders a field type's schema value. Scalars, enums, and lists of them stay
241/// on one line; object schemas (sibling record references) expand over
242/// multiple lines with their continuation at `indent` levels.
243fn render_type(ty: &GleamType, defs: &[TypeDef], indent: usize) -> String {
244    match ty {
245        GleamType::String => "{ \"type\": \"string\" }".to_owned(),
246        GleamType::Int => "{ \"type\": \"integer\" }".to_owned(),
247        GleamType::Float => "{ \"type\": \"number\" }".to_owned(),
248        GleamType::Bool => "{ \"type\": \"boolean\" }".to_owned(),
249        GleamType::List(inner) => format!(
250            "{{ \"type\": \"array\", \"items\": {} }}",
251            render_type(inner, defs, indent)
252        ),
253        GleamType::Named { type_name, .. } => {
254            match defs.iter().find(|def| def.type_name() == type_name) {
255                Some(TypeDef::Enum(definition)) => format!(
256                    "{{ \"type\": \"string\", \"enum\": [{}] }}",
257                    enum_values(definition)
258                ),
259                Some(TypeDef::Record(record)) => {
260                    let pad = "  ".repeat(indent);
261                    let mut nested = String::from("{\n");
262                    render_record_body(&mut nested, record, defs, indent + 1);
263                    let _ = write!(nested, "{pad}}}");
264                    nested
265                }
266                // The front-end guarantees every named reference resolves in
267                // the closure; an unresolvable one would already have failed
268                // there, so render nothing rather than panic.
269                None => "{ }".to_owned(),
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use std::path::PathBuf;
278
279    use super::{emit_schemas, render_schema};
280    use crate::codegen::error::CodegenError;
281    use crate::codegen::model::{
282        BoundaryType, EnumDef, EnumVariant, Field, GleamType, RecordDef, TypeDef,
283    };
284    use crate::codegen::project::CodegenMode;
285    use crate::project::fixture;
286
287    type TestResult = Result<(), Box<dyn std::error::Error>>;
288
289    fn named(type_name: &str) -> GleamType {
290        GleamType::Named {
291            type_name: type_name.to_owned(),
292            fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
293        }
294    }
295
296    fn field(wire: &str, ty: GleamType, required: bool) -> Field {
297        Field {
298            wire: wire.to_owned(),
299            ty,
300            required,
301        }
302    }
303
304    fn record_def(type_name: &str, fields: Vec<Field>) -> TypeDef {
305        TypeDef::Record(RecordDef {
306            type_name: type_name.to_owned(),
307            fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
308            fields,
309        })
310    }
311
312    fn boundary(type_name: &str, defs: Vec<TypeDef>) -> BoundaryType {
313        let stem = crate::codegen::names::pascal_to_snake(type_name);
314        BoundaryType {
315            file: PathBuf::from(format!("schemas/{stem}.json")),
316            stem,
317            root: named(type_name),
318            defs,
319        }
320    }
321
322    fn kind_enum() -> TypeDef {
323        TypeDef::Enum(EnumDef {
324            type_name: "OrderKind".to_owned(),
325            fn_prefix: "order_kind".to_owned(),
326            variants: vec![
327                EnumVariant {
328                    constructor: "OrderKindStandard".to_owned(),
329                    wire: "standard".to_owned(),
330                },
331                EnumVariant {
332                    constructor: "OrderKindRush".to_owned(),
333                    wire: "rush".to_owned(),
334                },
335            ],
336        })
337    }
338
339    #[test]
340    fn record_schema_renders_required_optional_list_enum_and_nested() {
341        let order = boundary(
342            "OrderInput",
343            vec![
344                record_def(
345                    "OrderInput",
346                    vec![
347                        field("order_id", GleamType::String, true),
348                        field("quantity", GleamType::Int, true),
349                        field("ratio", GleamType::Float, true),
350                        field("rush", GleamType::Bool, true),
351                        field("tags", GleamType::List(Box::new(GleamType::String)), true),
352                        field("kind", named("OrderKind"), true),
353                        field("line", named("OrderLine"), true),
354                        field("note", GleamType::String, false),
355                    ],
356                ),
357                kind_enum(),
358                record_def("OrderLine", vec![field("sku", GleamType::String, true)]),
359            ],
360        );
361
362        let expected = r#"{
363  "$comment": "Generated by aion generate from src/demo_io.gleam — do not edit; run `aion generate`.",
364  "type": "object",
365  "required": ["order_id", "quantity", "ratio", "rush", "tags", "kind", "line"],
366  "additionalProperties": false,
367  "properties": {
368    "order_id": { "type": "string" },
369    "quantity": { "type": "integer" },
370    "ratio": { "type": "number" },
371    "rush": { "type": "boolean" },
372    "tags": { "type": "array", "items": { "type": "string" } },
373    "kind": { "type": "string", "enum": ["standard", "rush"] },
374    "line": {
375      "type": "object",
376      "required": ["sku"],
377      "additionalProperties": false,
378      "properties": {
379        "sku": { "type": "string" }
380      }
381    },
382    "note": { "type": "string" }
383  }
384}
385"#;
386        assert_eq!(render_schema("demo", &order), expected);
387    }
388
389    #[test]
390    fn enum_schema_and_empty_record_render() {
391        let kind = boundary("OrderKind", vec![kind_enum()]);
392        assert_eq!(
393            render_schema("demo", &kind),
394            "{\n  \"$comment\": \"Generated by aion generate from src/demo_io.gleam — do not \
395             edit; run `aion generate`.\",\n  \"type\": \"string\",\n  \"enum\": [\"standard\", \
396             \"rush\"]\n}\n"
397        );
398
399        let blank = boundary("Blank", vec![record_def("Blank", Vec::new())]);
400        let rendered = render_schema("demo", &blank);
401        assert!(rendered.contains("\"required\": [],"));
402        assert!(rendered.contains("\"properties\": {}"));
403    }
404
405    #[test]
406    fn emitted_schemas_parse_as_json_and_are_deterministic() -> TestResult {
407        let order = boundary(
408            "OrderInput",
409            vec![record_def(
410                "OrderInput",
411                vec![
412                    field("order_id", GleamType::String, true),
413                    field("note", GleamType::String, false),
414                ],
415            )],
416        );
417        let first = render_schema("demo", &order);
418        let second = render_schema("demo", &order);
419        assert_eq!(first, second, "rendering must be deterministic");
420        let parsed: serde_json::Value = serde_json::from_str(&first)?;
421        assert_eq!(parsed["type"], "object");
422        assert_eq!(parsed["required"], serde_json::json!(["order_id"]));
423        assert_eq!(parsed["additionalProperties"], false);
424        assert_eq!(parsed["properties"]["note"]["type"], "string");
425        Ok(())
426    }
427
428    #[test]
429    fn write_then_check_round_trips_and_check_catches_a_hand_edit() -> TestResult {
430        let root = fixture::temp_project("schema-emit-roundtrip", &[])?;
431        let types = [boundary(
432            "OrderInput",
433            vec![record_def(
434                "OrderInput",
435                vec![field("order_id", GleamType::String, true)],
436            )],
437        )];
438
439        let report = emit_schemas(&root, "demo", &types, CodegenMode::Write)?;
440        assert!(report.written);
441        assert_eq!(report.emitted, vec!["schemas/order_input.json".to_owned()]);
442        assert!(root.join("schemas/order_input.json").is_file());
443
444        let checked = emit_schemas(&root, "demo", &types, CodegenMode::Check)?;
445        assert!(!checked.written);
446
447        let path = root.join("schemas/order_input.json");
448        let mut tampered = std::fs::read_to_string(&path)?;
449        tampered.push('\n');
450        std::fs::write(&path, &tampered)?;
451        let result = emit_schemas(&root, "demo", &types, CodegenMode::Check);
452        let Err(CodegenError::CheckDrift { path: drifted }) = result else {
453            std::fs::remove_dir_all(&root)?;
454            return Err(format!("expected CheckDrift, got {result:?}").into());
455        };
456        assert_eq!(drifted, path);
457        std::fs::remove_dir_all(&root)?;
458        Ok(())
459    }
460
461    #[test]
462    fn stray_unmarked_json_is_a_loud_error_with_the_migration_hint() -> TestResult {
463        let root = fixture::temp_project(
464            "schema-emit-stray",
465            &[("schemas/legacy.json", b"{ \"type\": \"object\" }" as &[u8])],
466        )?;
467        let types = [boundary(
468            "OrderInput",
469            vec![record_def(
470                "OrderInput",
471                vec![field("order_id", GleamType::String, true)],
472            )],
473        )];
474
475        let result = emit_schemas(&root, "demo", &types, CodegenMode::Write);
476        let Err(CodegenError::SchemaStray { path }) = result else {
477            std::fs::remove_dir_all(&root)?;
478            return Err(format!("expected SchemaStray, got {result:?}").into());
479        };
480        assert_eq!(path, root.join("schemas/legacy.json"));
481        let message = CodegenError::SchemaStray { path }.to_string();
482        assert!(
483            message.contains("src/<package>_io.gleam") && message.contains("aion generate"),
484            "the stray error must carry the migration hint: {message}"
485        );
486        std::fs::remove_dir_all(&root)?;
487        Ok(())
488    }
489
490    #[test]
491    fn stale_marked_schema_is_removed_on_write_and_drift_under_check() -> TestResult {
492        let root = fixture::temp_project("schema-emit-stale", &[])?;
493        let both = [
494            boundary("NewType", vec![record_def("NewType", Vec::new())]),
495            boundary("OldType", vec![record_def("OldType", Vec::new())]),
496        ];
497        emit_schemas(&root, "demo", &both, CodegenMode::Write)?;
498        assert!(root.join("schemas/old_type.json").is_file());
499
500        // The model moves on: OldType is renamed away, so its emitted schema
501        // is now a stale generated artifact.
502        let new = [boundary("NewType", vec![record_def("NewType", Vec::new())])];
503        // Under --check the stale marked file is drift.
504        let result = emit_schemas(&root, "demo", &new, CodegenMode::Check);
505        assert!(
506            matches!(result, Err(CodegenError::CheckDrift { ref path }) if path.ends_with("old_type.json")),
507            "stale marked schema must be drift under check: {result:?}"
508        );
509        // A write run removes it.
510        let report = emit_schemas(&root, "demo", &new, CodegenMode::Write)?;
511        assert_eq!(report.removed, vec!["schemas/old_type.json".to_owned()]);
512        assert!(!root.join("schemas/old_type.json").exists());
513        assert!(root.join("schemas/new_type.json").is_file());
514        std::fs::remove_dir_all(&root)?;
515        Ok(())
516    }
517}