1use 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
34const SCHEMAS_DIR: &str = "schemas";
37
38const MARKER_PREFIX: &str = "Generated by aion generate from src/";
42
43#[derive(Debug)]
45pub struct SchemaEmitReport {
46 pub emitted: Vec<String>,
49 pub removed: Vec<String>,
51 pub written: bool,
53}
54
55pub 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
108fn 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 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
176fn 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
189pub(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
212fn 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
223fn 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
253fn 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
260fn enum_values(definition: &EnumDef) -> String {
262 definition
263 .variants
264 .iter()
265 .map(|variant| format!("\"{}\"", variant.wire))
266 .collect::<Vec<_>>()
267 .join(", ")
268}
269
270fn 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 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 let new = [boundary("NewType", vec![record_def("NewType", Vec::new())])];
533 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 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}