use std::fmt::Write as _;
use std::io;
use std::path::{Path, PathBuf};
use super::error::CodegenError;
use super::model::{BoundaryType, EnumDef, GleamType, RecordDef, TypeDef};
use super::project::{CodegenMode, check_on_disk};
const SCHEMAS_DIR: &str = "schemas";
const MARKER_PREFIX: &str = "Generated by aion generate from src/";
#[derive(Debug)]
pub struct SchemaEmitReport {
pub emitted: Vec<String>,
pub removed: Vec<String>,
pub written: bool,
}
pub fn emit_schemas(
root: &Path,
package_name: &str,
types: &[BoundaryType],
mode: CodegenMode,
) -> Result<SchemaEmitReport, CodegenError> {
let schemas_dir = root.join(SCHEMAS_DIR);
let mut emitted = Vec::with_capacity(types.len());
let mut rendered: Vec<(PathBuf, String)> = Vec::with_capacity(types.len());
for boundary in types {
let contents = render_schema(package_name, boundary);
emitted.push(boundary.file.display().to_string());
rendered.push((root.join(&boundary.file), contents));
}
let written = match mode {
CodegenMode::Write => {
std::fs::create_dir_all(&schemas_dir).map_err(|source| CodegenError::Write {
path: schemas_dir.clone(),
source,
})?;
for (path, contents) in &rendered {
std::fs::write(path, contents).map_err(|source| CodegenError::Write {
path: path.clone(),
source,
})?;
}
true
}
CodegenMode::Check => {
for (path, contents) in &rendered {
check_on_disk(path, contents)?;
}
false
}
};
let removed = reconcile_directory(&schemas_dir, &emitted, mode)?;
Ok(SchemaEmitReport {
emitted,
removed,
written,
})
}
fn reconcile_directory(
schemas_dir: &Path,
emitted: &[String],
mode: CodegenMode,
) -> Result<Vec<String>, CodegenError> {
let entries = match std::fs::read_dir(schemas_dir) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(CodegenError::SchemasDirRead {
path: schemas_dir.to_path_buf(),
source,
});
}
};
let mut removed = Vec::new();
let mut names: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| CodegenError::SchemasDirRead {
path: schemas_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
names.push(path);
}
}
names.sort();
for path in names {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let relative = format!("{SCHEMAS_DIR}/{file_name}");
if emitted.contains(&relative) {
continue;
}
let contents =
std::fs::read_to_string(&path).map_err(|source| CodegenError::CheckRead {
path: path.clone(),
source,
})?;
if !contents.contains(MARKER_PREFIX) {
return Err(CodegenError::SchemaStray { path });
}
match mode {
CodegenMode::Write => {
std::fs::remove_file(&path).map_err(|source| CodegenError::Write {
path: path.clone(),
source,
})?;
removed.push(relative);
}
CodegenMode::Check => {
return Err(CodegenError::CheckDrift { path });
}
}
}
Ok(removed)
}
fn render_schema(package_name: &str, boundary: &BoundaryType) -> String {
let mut out = String::new();
out.push_str("{\n");
let _ = writeln!(
out,
" \"$comment\": \"{MARKER_PREFIX}{package_name}_io.gleam — do not edit; run `aion generate`.\","
);
match boundary.defs.first() {
Some(TypeDef::Record(record)) => render_record_body(&mut out, record, &boundary.defs, 1),
Some(TypeDef::Enum(definition)) => render_enum_body(&mut out, definition, 1),
None => {}
}
out.push_str("}\n");
out
}
fn render_record_body(out: &mut String, record: &RecordDef, defs: &[TypeDef], indent: usize) {
let pad = " ".repeat(indent);
let _ = writeln!(out, "{pad}\"type\": \"object\",");
let required: Vec<String> = record
.fields
.iter()
.filter(|field| field.required)
.map(|field| format!("\"{}\"", field.wire))
.collect();
let _ = writeln!(out, "{pad}\"required\": [{}],", required.join(", "));
let _ = writeln!(out, "{pad}\"additionalProperties\": false,");
if record.fields.is_empty() {
let _ = writeln!(out, "{pad}\"properties\": {{}}");
return;
}
let _ = writeln!(out, "{pad}\"properties\": {{");
for (position, field) in record.fields.iter().enumerate() {
let comma = if position + 1 == record.fields.len() {
""
} else {
","
};
let value = render_type(&field.ty, defs, indent + 1);
let _ = writeln!(out, "{pad} \"{}\": {value}{comma}", field.wire);
}
let _ = writeln!(out, "{pad}}}");
}
fn render_enum_body(out: &mut String, definition: &EnumDef, indent: usize) {
let pad = " ".repeat(indent);
let _ = writeln!(out, "{pad}\"type\": \"string\",");
let _ = writeln!(out, "{pad}\"enum\": [{}]", enum_values(definition));
}
fn enum_values(definition: &EnumDef) -> String {
definition
.variants
.iter()
.map(|variant| format!("\"{}\"", variant.wire))
.collect::<Vec<_>>()
.join(", ")
}
fn render_type(ty: &GleamType, defs: &[TypeDef], indent: usize) -> String {
match ty {
GleamType::String => "{ \"type\": \"string\" }".to_owned(),
GleamType::Int => "{ \"type\": \"integer\" }".to_owned(),
GleamType::Float => "{ \"type\": \"number\" }".to_owned(),
GleamType::Bool => "{ \"type\": \"boolean\" }".to_owned(),
GleamType::List(inner) => format!(
"{{ \"type\": \"array\", \"items\": {} }}",
render_type(inner, defs, indent)
),
GleamType::Named { type_name, .. } => {
match defs.iter().find(|def| def.type_name() == type_name) {
Some(TypeDef::Enum(definition)) => format!(
"{{ \"type\": \"string\", \"enum\": [{}] }}",
enum_values(definition)
),
Some(TypeDef::Record(record)) => {
let pad = " ".repeat(indent);
let mut nested = String::from("{\n");
render_record_body(&mut nested, record, defs, indent + 1);
let _ = write!(nested, "{pad}}}");
nested
}
None => "{ }".to_owned(),
}
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{emit_schemas, render_schema};
use crate::codegen::error::CodegenError;
use crate::codegen::model::{
BoundaryType, EnumDef, EnumVariant, Field, GleamType, RecordDef, TypeDef,
};
use crate::codegen::project::CodegenMode;
use crate::project::fixture;
type TestResult = Result<(), Box<dyn std::error::Error>>;
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 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,
})
}
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 kind_enum() -> TypeDef {
TypeDef::Enum(EnumDef {
type_name: "OrderKind".to_owned(),
fn_prefix: "order_kind".to_owned(),
variants: vec![
EnumVariant {
constructor: "OrderKindStandard".to_owned(),
wire: "standard".to_owned(),
},
EnumVariant {
constructor: "OrderKindRush".to_owned(),
wire: "rush".to_owned(),
},
],
})
}
#[test]
fn record_schema_renders_required_optional_list_enum_and_nested() {
let order = boundary(
"OrderInput",
vec![
record_def(
"OrderInput",
vec![
field("order_id", GleamType::String, true),
field("quantity", GleamType::Int, true),
field("ratio", GleamType::Float, true),
field("rush", GleamType::Bool, true),
field("tags", GleamType::List(Box::new(GleamType::String)), true),
field("kind", named("OrderKind"), true),
field("line", named("OrderLine"), true),
field("note", GleamType::String, false),
],
),
kind_enum(),
record_def("OrderLine", vec![field("sku", GleamType::String, true)]),
],
);
let expected = r#"{
"$comment": "Generated by aion generate from src/demo_io.gleam — do not edit; run `aion generate`.",
"type": "object",
"required": ["order_id", "quantity", "ratio", "rush", "tags", "kind", "line"],
"additionalProperties": false,
"properties": {
"order_id": { "type": "string" },
"quantity": { "type": "integer" },
"ratio": { "type": "number" },
"rush": { "type": "boolean" },
"tags": { "type": "array", "items": { "type": "string" } },
"kind": { "type": "string", "enum": ["standard", "rush"] },
"line": {
"type": "object",
"required": ["sku"],
"additionalProperties": false,
"properties": {
"sku": { "type": "string" }
}
},
"note": { "type": "string" }
}
}
"#;
assert_eq!(render_schema("demo", &order), expected);
}
#[test]
fn enum_schema_and_empty_record_render() {
let kind = boundary("OrderKind", vec![kind_enum()]);
assert_eq!(
render_schema("demo", &kind),
"{\n \"$comment\": \"Generated by aion generate from src/demo_io.gleam — do not \
edit; run `aion generate`.\",\n \"type\": \"string\",\n \"enum\": [\"standard\", \
\"rush\"]\n}\n"
);
let blank = boundary("Blank", vec![record_def("Blank", Vec::new())]);
let rendered = render_schema("demo", &blank);
assert!(rendered.contains("\"required\": [],"));
assert!(rendered.contains("\"properties\": {}"));
}
#[test]
fn emitted_schemas_parse_as_json_and_are_deterministic() -> TestResult {
let order = boundary(
"OrderInput",
vec![record_def(
"OrderInput",
vec![
field("order_id", GleamType::String, true),
field("note", GleamType::String, false),
],
)],
);
let first = render_schema("demo", &order);
let second = render_schema("demo", &order);
assert_eq!(first, second, "rendering must be deterministic");
let parsed: serde_json::Value = serde_json::from_str(&first)?;
assert_eq!(parsed["type"], "object");
assert_eq!(parsed["required"], serde_json::json!(["order_id"]));
assert_eq!(parsed["additionalProperties"], false);
assert_eq!(parsed["properties"]["note"]["type"], "string");
Ok(())
}
#[test]
fn write_then_check_round_trips_and_check_catches_a_hand_edit() -> TestResult {
let root = fixture::temp_project("schema-emit-roundtrip", &[])?;
let types = [boundary(
"OrderInput",
vec![record_def(
"OrderInput",
vec![field("order_id", GleamType::String, true)],
)],
)];
let report = emit_schemas(&root, "demo", &types, CodegenMode::Write)?;
assert!(report.written);
assert_eq!(report.emitted, vec!["schemas/order_input.json".to_owned()]);
assert!(root.join("schemas/order_input.json").is_file());
let checked = emit_schemas(&root, "demo", &types, CodegenMode::Check)?;
assert!(!checked.written);
let path = root.join("schemas/order_input.json");
let mut tampered = std::fs::read_to_string(&path)?;
tampered.push('\n');
std::fs::write(&path, &tampered)?;
let result = emit_schemas(&root, "demo", &types, CodegenMode::Check);
let Err(CodegenError::CheckDrift { path: drifted }) = result else {
std::fs::remove_dir_all(&root)?;
return Err(format!("expected CheckDrift, got {result:?}").into());
};
assert_eq!(drifted, path);
std::fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn stray_unmarked_json_is_a_loud_error_with_the_migration_hint() -> TestResult {
let root = fixture::temp_project(
"schema-emit-stray",
&[("schemas/legacy.json", b"{ \"type\": \"object\" }" as &[u8])],
)?;
let types = [boundary(
"OrderInput",
vec![record_def(
"OrderInput",
vec![field("order_id", GleamType::String, true)],
)],
)];
let result = emit_schemas(&root, "demo", &types, CodegenMode::Write);
let Err(CodegenError::SchemaStray { path }) = result else {
std::fs::remove_dir_all(&root)?;
return Err(format!("expected SchemaStray, got {result:?}").into());
};
assert_eq!(path, root.join("schemas/legacy.json"));
let message = CodegenError::SchemaStray { path }.to_string();
assert!(
message.contains("src/<package>_io.gleam") && message.contains("aion generate"),
"the stray error must carry the migration hint: {message}"
);
std::fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn stale_marked_schema_is_removed_on_write_and_drift_under_check() -> TestResult {
let root = fixture::temp_project("schema-emit-stale", &[])?;
let both = [
boundary("NewType", vec![record_def("NewType", Vec::new())]),
boundary("OldType", vec![record_def("OldType", Vec::new())]),
];
emit_schemas(&root, "demo", &both, CodegenMode::Write)?;
assert!(root.join("schemas/old_type.json").is_file());
let new = [boundary("NewType", vec![record_def("NewType", Vec::new())])];
let result = emit_schemas(&root, "demo", &new, CodegenMode::Check);
assert!(
matches!(result, Err(CodegenError::CheckDrift { ref path }) if path.ends_with("old_type.json")),
"stale marked schema must be drift under check: {result:?}"
);
let report = emit_schemas(&root, "demo", &new, CodegenMode::Write)?;
assert_eq!(report.removed, vec!["schemas/old_type.json".to_owned()]);
assert!(!root.join("schemas/old_type.json").exists());
assert!(root.join("schemas/new_type.json").is_file());
std::fs::remove_dir_all(&root)?;
Ok(())
}
}