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 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
193fn 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
223fn 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
230fn enum_values(definition: &EnumDef) -> String {
232 definition
233 .variants
234 .iter()
235 .map(|variant| format!("\"{}\"", variant.wire))
236 .collect::<Vec<_>>()
237 .join(", ")
238}
239
240fn 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 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 let new = [boundary("NewType", vec![record_def("NewType", Vec::new())])];
503 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 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}