use std::fmt::Write as _;
use super::model::{BoundaryType, EnumDef, Field, GleamType, RecordDef, TypeDef};
pub(crate) const GENERATED_TYPES_HEADER: &str =
"//// Generated by aion generate — do not edit; regenerate from the project's types module.";
pub(crate) fn emit_codecs_module(package_name: &str, types: &[BoundaryType]) -> String {
let has_optionals = types.iter().any(|boundary| {
own_def(boundary).is_some_and(|def| match def {
TypeDef::Record(record) => record.fields.iter().any(|field| !field.required),
TypeDef::Enum(_) => false,
})
});
let mut out = String::new();
let _ = writeln!(out, "{GENERATED_TYPES_HEADER}");
out.push_str("////\n");
let _ = writeln!(
out,
"//// JSON codecs for the `{package_name}` boundary types declared in"
);
let _ = writeln!(
out,
"//// `src/{package_name}_io.gleam`: per type, a schema-shaped encoder/decoder"
);
out.push_str("//// pair and a typed codec for activity and workflow I/O.\n");
out.push('\n');
out.push_str("import aion/codec\n");
out.push_str("import gleam/dynamic/decode\n");
out.push_str("import gleam/json\n");
if has_optionals {
out.push_str("import gleam/list\n");
out.push_str("import gleam/option\n");
}
let _ = writeln!(out, "import {package_name}_io as io");
for boundary in types {
match own_def(boundary) {
Some(TypeDef::Record(record)) => emit_record_codecs(&mut out, record),
Some(TypeDef::Enum(definition)) => emit_enum_codecs(&mut out, definition),
None => {}
}
}
out
}
fn own_def(boundary: &BoundaryType) -> Option<&TypeDef> {
boundary.defs.first()
}
fn emit_record_codecs(out: &mut String, record: &RecordDef) {
let name = &record.type_name;
let prefix = &record.fn_prefix;
let _ = write!(
out,
"\n/// Encodes `io.{name}` as schema-shaped JSON; optional fields are\n\
/// omitted when `None`.\n\
pub fn {prefix}_to_json(value: io.{name}) -> json.Json {{\n"
);
if record.fields.iter().all(|field| field.required) {
out.push_str(" json.object([\n");
for field in &record.fields {
let _ = writeln!(out, " {},", field_pair(field));
}
out.push_str(" ])\n");
} else {
out.push_str(" json.object(\n list.flatten([\n");
for field in &record.fields {
if field.required {
let _ = writeln!(out, " [{}],", field_pair(field));
} else {
let _ = writeln!(out, " case value.{} {{", field.wire);
let _ = writeln!(
out,
" option.Some(present) -> [#(\"{}\", {})]",
field.wire,
encode_call(&field.ty, "present", 0)
);
out.push_str(" option.None -> []\n },\n");
}
}
out.push_str(" ]),\n )\n");
}
out.push_str("}\n");
let _ = write!(
out,
"\n/// Decoder for `io.{name}` from schema-shaped JSON.\n\
pub fn {prefix}_decoder() -> decode.Decoder(io.{name}) {{\n"
);
for field in &record.fields {
if field.required {
let _ = writeln!(
out,
" use field_{wire} <- decode.field(\"{wire}\", {})",
decoder_expr(&field.ty),
wire = field.wire,
);
} else {
let _ = writeln!(
out,
" use field_{wire} <- decode.optional_field(\n \"{wire}\",\n \
option.None,\n decode.optional({}),\n )",
decoder_expr(&field.ty),
wire = field.wire,
);
}
}
if record.fields.is_empty() {
let _ = writeln!(out, " decode.success(io.{name})");
} else {
let _ = writeln!(out, " decode.success(io.{name}(");
for field in &record.fields {
let _ = writeln!(out, " {wire}: field_{wire},", wire = field.wire);
}
out.push_str(" ))\n");
}
out.push_str("}\n");
emit_codec_wrapper(out, name, prefix);
}
fn emit_enum_codecs(out: &mut String, definition: &EnumDef) {
let Some(first_variant) = definition.variants.first() else {
return;
};
let name = &definition.type_name;
let prefix = &definition.fn_prefix;
let _ = write!(
out,
"\n/// Encodes `io.{name}` as its wire string.\n\
pub fn {prefix}_to_json(value: io.{name}) -> json.Json {{\n case value {{\n"
);
for variant in &definition.variants {
let _ = writeln!(
out,
" io.{} -> json.string(\"{}\")",
variant.constructor, variant.wire
);
}
out.push_str(" }\n}\n");
let _ = write!(
out,
"\n/// Decoder for `io.{name}` from its wire string.\n\
pub fn {prefix}_decoder() -> decode.Decoder(io.{name}) {{\n \
decode.then(decode.string, fn(raw) {{\n case raw {{\n"
);
for variant in &definition.variants {
let _ = writeln!(
out,
" \"{}\" -> decode.success(io.{})",
variant.wire, variant.constructor
);
}
let _ = writeln!(
out,
" _ -> decode.failure(io.{}, \"{name}\")",
first_variant.constructor
);
out.push_str(" }\n })\n}\n");
emit_codec_wrapper(out, name, prefix);
}
fn emit_codec_wrapper(out: &mut String, name: &str, prefix: &str) {
let _ = write!(
out,
"\n/// Typed codec for `io.{name}` (activity and workflow I/O).\n\
pub fn {prefix}_codec() -> codec.Codec(io.{name}) {{\n \
codec.json_codec({prefix}_to_json, {prefix}_decoder())\n}}\n"
);
}
fn field_pair(field: &Field) -> String {
format!(
"#(\"{wire}\", {})",
encode_call(&field.ty, &format!("value.{}", field.wire), 0),
wire = field.wire,
)
}
fn encode_call(ty: &GleamType, expr: &str, depth: usize) -> String {
match ty {
GleamType::String => format!("json.string({expr})"),
GleamType::Int => format!("json.int({expr})"),
GleamType::Float => format!("json.float({expr})"),
GleamType::Bool => format!("json.bool({expr})"),
GleamType::List(inner) => format!("json.array({expr}, {})", encode_fn(inner, depth)),
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_to_json({expr})"),
}
}
fn encode_fn(ty: &GleamType, depth: usize) -> String {
match ty {
GleamType::String => "json.string".to_owned(),
GleamType::Int => "json.int".to_owned(),
GleamType::Float => "json.float".to_owned(),
GleamType::Bool => "json.bool".to_owned(),
GleamType::List(inner) => {
let var = format!("items{depth}");
format!(
"fn({var}) {{ json.array({var}, {}) }}",
encode_fn(inner, depth + 1)
)
}
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_to_json"),
}
}
fn decoder_expr(ty: &GleamType) -> String {
match ty {
GleamType::String => "decode.string".to_owned(),
GleamType::Int => "decode.int".to_owned(),
GleamType::Float => "decode.float".to_owned(),
GleamType::Bool => "decode.bool".to_owned(),
GleamType::List(inner) => format!("decode.list({})", decoder_expr(inner)),
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_decoder()"),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{GENERATED_TYPES_HEADER, emit_codecs_module};
use crate::codegen::model::{
BoundaryType, EnumDef, EnumVariant, Field, GleamType, RecordDef, TypeDef,
};
fn named(type_name: &str) -> GleamType {
GleamType::Named {
type_name: type_name.to_owned(),
fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
}
}
fn field(wire: &str, ty: GleamType, required: bool) -> Field {
Field {
wire: wire.to_owned(),
ty,
required,
}
}
fn boundary(type_name: &str, defs: Vec<TypeDef>) -> BoundaryType {
let stem = crate::codegen::names::pascal_to_snake(type_name);
BoundaryType {
file: PathBuf::from(format!("schemas/{stem}.json")),
stem,
root: named(type_name),
defs,
}
}
fn record_def(type_name: &str, fields: Vec<Field>) -> TypeDef {
TypeDef::Record(RecordDef {
type_name: type_name.to_owned(),
fn_prefix: crate::codegen::names::pascal_to_snake(type_name),
fields,
})
}
const FULL_MODULE_GOLDEN: &str = r#"//// Generated by aion generate — do not edit; regenerate from the project's types module.
////
//// JSON codecs for the `demo` boundary types declared in
//// `src/demo_io.gleam`: per type, a schema-shaped encoder/decoder
//// pair and a typed codec for activity and workflow I/O.
import aion/codec
import gleam/dynamic/decode
import gleam/json
import gleam/list
import gleam/option
import demo_io as io
/// Encodes `io.Event` as schema-shaped JSON; optional fields are
/// omitted when `None`.
pub fn event_to_json(value: io.Event) -> json.Json {
json.object(
list.flatten([
[#("kind", event_kind_to_json(value.kind))],
[#("tags", json.array(value.tags, json.string))],
case value.note {
option.Some(present) -> [#("note", json.string(present))]
option.None -> []
},
]),
)
}
/// Decoder for `io.Event` from schema-shaped JSON.
pub fn event_decoder() -> decode.Decoder(io.Event) {
use field_kind <- decode.field("kind", event_kind_decoder())
use field_tags <- decode.field("tags", decode.list(decode.string))
use field_note <- decode.optional_field(
"note",
option.None,
decode.optional(decode.string),
)
decode.success(io.Event(
kind: field_kind,
tags: field_tags,
note: field_note,
))
}
/// Typed codec for `io.Event` (activity and workflow I/O).
pub fn event_codec() -> codec.Codec(io.Event) {
codec.json_codec(event_to_json, event_decoder())
}
/// Encodes `io.EventKind` as its wire string.
pub fn event_kind_to_json(value: io.EventKind) -> json.Json {
case value {
io.EventKindCreated -> json.string("created")
io.EventKindClosedOut -> json.string("closed_out")
}
}
/// Decoder for `io.EventKind` from its wire string.
pub fn event_kind_decoder() -> decode.Decoder(io.EventKind) {
decode.then(decode.string, fn(raw) {
case raw {
"created" -> decode.success(io.EventKindCreated)
"closed_out" -> decode.success(io.EventKindClosedOut)
_ -> decode.failure(io.EventKindCreated, "EventKind")
}
})
}
/// Typed codec for `io.EventKind` (activity and workflow I/O).
pub fn event_kind_codec() -> codec.Codec(io.EventKind) {
codec.json_codec(event_kind_to_json, event_kind_decoder())
}
"#;
#[test]
fn full_module_golden_with_enum_list_and_optional() {
let event = boundary(
"Event",
vec![
record_def(
"Event",
vec![
field("kind", named("EventKind"), true),
field("tags", GleamType::List(Box::new(GleamType::String)), true),
field("note", GleamType::String, false),
],
),
TypeDef::Enum(EnumDef {
type_name: "EventKind".to_owned(),
fn_prefix: "event_kind".to_owned(),
variants: vec![
EnumVariant {
constructor: "EventKindCreated".to_owned(),
wire: "created".to_owned(),
},
EnumVariant {
constructor: "EventKindClosedOut".to_owned(),
wire: "closed_out".to_owned(),
},
],
}),
],
);
let kind = boundary(
"EventKind",
vec![TypeDef::Enum(EnumDef {
type_name: "EventKind".to_owned(),
fn_prefix: "event_kind".to_owned(),
variants: vec![
EnumVariant {
constructor: "EventKindCreated".to_owned(),
wire: "created".to_owned(),
},
EnumVariant {
constructor: "EventKindClosedOut".to_owned(),
wire: "closed_out".to_owned(),
},
],
})],
);
assert_eq!(
emit_codecs_module("demo", &[event, kind]),
FULL_MODULE_GOLDEN
);
}
#[test]
fn all_required_records_use_plain_object_lists_without_option_imports() {
let pair = boundary(
"Pair",
vec![record_def(
"Pair",
vec![
field("count", GleamType::Int, true),
field("ratio", GleamType::Float, true),
field("flag", GleamType::Bool, true),
],
)],
);
let module = emit_codecs_module("demo", &[pair]);
assert!(module.starts_with(GENERATED_TYPES_HEADER));
assert!(!module.contains("import gleam/list"));
assert!(!module.contains("import gleam/option"));
assert!(module.contains(
"pub fn pair_to_json(value: io.Pair) -> json.Json {\n json.object([\n \
#(\"count\", json.int(value.count)),\n \
#(\"ratio\", json.float(value.ratio)),\n \
#(\"flag\", json.bool(value.flag)),\n ])\n}\n"
));
assert!(module.contains("use field_count <- decode.field(\"count\", decode.int)\n"));
assert!(module.contains(
"pub fn pair_codec() -> codec.Codec(io.Pair) {\n \
codec.json_codec(pair_to_json, pair_decoder())\n}\n"
));
assert!(!module.contains("list.flatten"));
}
#[test]
fn nested_lists_encode_with_depth_named_lambdas() {
let grid = boundary(
"Grid",
vec![record_def(
"Grid",
vec![field(
"matrix",
GleamType::List(Box::new(GleamType::List(Box::new(GleamType::Int)))),
true,
)],
)],
);
let module = emit_codecs_module("demo", &[grid]);
assert!(module.contains(
"#(\"matrix\", json.array(value.matrix, fn(items0) { json.array(items0, json.int) }))"
));
assert!(module.contains(
"use field_matrix <- decode.field(\"matrix\", decode.list(decode.list(decode.int)))"
));
}
#[test]
fn empty_records_emit_bare_constructors() {
let blank = boundary("Blank", vec![record_def("Blank", Vec::new())]);
let module = emit_codecs_module("demo", &[blank]);
assert!(module.contains("json.object([\n ])"));
assert!(module.contains("decode.success(io.Blank)\n"));
}
#[test]
fn emission_is_deterministic() {
let make = || {
boundary(
"Event",
vec![record_def(
"Event",
vec![
field("kind", GleamType::String, true),
field("note", GleamType::String, false),
],
)],
)
};
assert_eq!(
emit_codecs_module("demo", &[make()]),
emit_codecs_module("demo", &[make()])
);
}
}