1use std::io;
11use std::path::{Path, PathBuf};
12
13use serde::Deserialize;
14
15use super::emit;
16use super::error::CodegenError;
17use super::json;
18use super::names::{NameRegistry, is_reserved_word, is_snake_identifier};
19use super::schema::{self, SchemaArtifact};
20use crate::PackagingError;
21use crate::project::config;
22
23const SCHEMAS_DIR: &str = "schemas";
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum CodegenMode {
29 Write,
31 Check,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct CodegenReport {
39 pub module_path: PathBuf,
41 pub module_relative: String,
43 pub schemas: Vec<String>,
46 pub contents: String,
48 pub written: bool,
50}
51
52pub fn codegen_project(root: &Path, mode: CodegenMode) -> Result<CodegenReport, CodegenError> {
68 let package_name = read_package_name(root)?;
69 let project_config = config::load_config(root)?;
70 let schemas_dir = root.join(SCHEMAS_DIR);
71 for (index, workflow) in project_config.workflows.iter().enumerate() {
72 for (field, path) in [
73 ("input_schema", &workflow.input_schema_path),
74 ("output_schema", &workflow.output_schema_path),
75 ] {
76 if path.parent() != Some(schemas_dir.as_path()) {
77 return Err(CodegenError::SchemaOutsideSchemasDir {
78 field: format!("workflow[{index}].{field}"),
79 path: path.clone(),
80 });
81 }
82 }
83 }
84
85 let artifacts = parse_project_schemas(root)?;
86
87 let contents = emit::emit_module(&package_name, &artifacts);
88 let module_relative = format!("src/{package_name}_io.gleam");
89 let module_path = root.join("src").join(format!("{package_name}_io.gleam"));
90 let written = match mode {
91 CodegenMode::Write => {
92 std::fs::write(&module_path, &contents).map_err(|source| CodegenError::Write {
93 path: module_path.clone(),
94 source,
95 })?;
96 true
97 }
98 CodegenMode::Check => {
99 check_on_disk(&module_path, &contents)?;
100 false
101 }
102 };
103
104 Ok(CodegenReport {
105 module_path,
106 module_relative,
107 schemas: artifacts
108 .iter()
109 .map(|artifact| artifact.file.display().to_string())
110 .collect(),
111 contents,
112 written,
113 })
114}
115
116pub(crate) fn parse_project_schemas(root: &Path) -> Result<Vec<SchemaArtifact>, CodegenError> {
128 let schemas_dir = root.join(SCHEMAS_DIR);
129 let file_names = list_schema_file_names(&schemas_dir)?;
130 let mut registry = NameRegistry::default();
131 let mut artifacts: Vec<SchemaArtifact> = Vec::with_capacity(file_names.len());
132 for file_name in &file_names {
133 artifacts.push(parse_one_schema(&schemas_dir, file_name, &mut registry)?);
134 }
135 Ok(artifacts)
136}
137
138fn list_schema_file_names(schemas_dir: &Path) -> Result<Vec<String>, CodegenError> {
142 let entries = match std::fs::read_dir(schemas_dir) {
143 Ok(entries) => entries,
144 Err(source) if source.kind() == io::ErrorKind::NotFound => {
145 return Err(CodegenError::SchemasDirMissing {
146 path: schemas_dir.to_path_buf(),
147 });
148 }
149 Err(source) => {
150 return Err(CodegenError::SchemasDirRead {
151 path: schemas_dir.to_path_buf(),
152 source,
153 });
154 }
155 };
156 let mut names = Vec::new();
157 for entry in entries {
158 let entry = entry.map_err(|source| CodegenError::SchemasDirRead {
159 path: schemas_dir.to_path_buf(),
160 source,
161 })?;
162 let path = entry.path();
163 if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") {
164 continue;
165 }
166 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
167 return Err(CodegenError::SchemaFileName {
168 path,
169 reason: "file name is not valid UTF-8".to_owned(),
170 });
171 };
172 names.push(name.to_owned());
173 }
174 if names.is_empty() {
175 return Err(CodegenError::SchemasDirEmpty {
176 path: schemas_dir.to_path_buf(),
177 });
178 }
179 names.sort();
180 Ok(names)
181}
182
183fn parse_one_schema(
185 schemas_dir: &Path,
186 file_name: &str,
187 registry: &mut NameRegistry,
188) -> Result<SchemaArtifact, CodegenError> {
189 let path = schemas_dir.join(file_name);
190 let relative = PathBuf::from(SCHEMAS_DIR).join(file_name);
191 let Some(stem) = Path::new(file_name)
192 .file_stem()
193 .and_then(|stem| stem.to_str())
194 else {
195 return Err(CodegenError::SchemaFileName {
196 path: relative,
197 reason: "file name has no stem".to_owned(),
198 });
199 };
200 let bytes = std::fs::read(&path).map_err(|source| CodegenError::SchemaRead {
201 path: path.clone(),
202 source,
203 })?;
204 let document = json::parse_ordered(&bytes).map_err(|source| CodegenError::SchemaParse {
205 path: path.clone(),
206 source,
207 })?;
208 schema::parse_schema(&relative, stem, &document, registry)
209}
210
211pub(crate) fn check_on_disk(module_path: &Path, contents: &str) -> Result<(), CodegenError> {
212 let on_disk = match std::fs::read(module_path) {
213 Ok(bytes) => bytes,
214 Err(source) if source.kind() == io::ErrorKind::NotFound => {
215 return Err(CodegenError::CheckMissing {
216 path: module_path.to_path_buf(),
217 });
218 }
219 Err(source) => {
220 return Err(CodegenError::CheckRead {
221 path: module_path.to_path_buf(),
222 source,
223 });
224 }
225 };
226 if on_disk != contents.as_bytes() {
227 return Err(CodegenError::CheckDrift {
228 path: module_path.to_path_buf(),
229 });
230 }
231 Ok(())
232}
233
234#[derive(Debug, Deserialize)]
235struct GleamTomlName {
236 name: String,
237}
238
239pub(crate) fn read_package_name(root: &Path) -> Result<String, CodegenError> {
242 let path = root.join("gleam.toml");
243 let text = match std::fs::read_to_string(&path) {
244 Ok(text) => text,
245 Err(source) if source.kind() == io::ErrorKind::NotFound => {
246 return Err(CodegenError::Config(PackagingError::GleamTomlMissing {
247 path,
248 }));
249 }
250 Err(source) => {
251 return Err(CodegenError::Config(PackagingError::GleamMetadataRead {
252 path,
253 source,
254 }));
255 }
256 };
257 let parsed: GleamTomlName = toml::from_str(&text).map_err(|source| {
258 CodegenError::Config(PackagingError::GleamMetadataParse { path, source })
259 })?;
260 if !is_snake_identifier(&parsed.name) || is_reserved_word(&parsed.name) {
261 return Err(CodegenError::ProjectName {
262 name: parsed.name,
263 reason: "must be a snake_case identifier and not a Gleam reserved word".to_owned(),
264 });
265 }
266 Ok(parsed.name)
267}
268
269#[cfg(test)]
270mod tests {
271 use std::fs;
272 use std::path::{Path, PathBuf};
273
274 use super::{CodegenMode, codegen_project, list_schema_file_names};
275 use crate::PackagingError;
276 use crate::codegen::error::CodegenError;
277 use crate::project::fixture;
278
279 type TestResult = Result<(), Box<dyn std::error::Error>>;
280
281 const GLEAM_TOML: &str = "name = \"demo\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n";
282
283 const WORKFLOW_TOML: &str = r#"[[workflow]]
284entry_module = "demo"
285entry_function = "run"
286timeout_seconds = 30
287input_schema = "schemas/input.json"
288output_schema = "schemas/output.json"
289activities = []
290"#;
291
292 const INPUT_SCHEMA: &[u8] = br#"{
293 "type": "object",
294 "required": ["name"],
295 "additionalProperties": false,
296 "properties": {
297 "name": { "type": "string" },
298 "note": { "type": "string" }
299 }
300}"#;
301
302 const OUTPUT_SCHEMA: &[u8] = br#"{ "type": "string" }"#;
303
304 fn project(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
305 fixture::temp_project(
306 label,
307 &[
308 ("gleam.toml", GLEAM_TOML.as_bytes()),
309 ("workflow.toml", WORKFLOW_TOML.as_bytes()),
310 ("schemas/input.json", INPUT_SCHEMA),
311 ("schemas/output.json", OUTPUT_SCHEMA),
312 ("src/demo.gleam", b"pub fn run() { Nil }"),
313 ],
314 )
315 }
316
317 #[test]
318 fn write_mode_generates_the_module_with_header_and_report() -> TestResult {
319 let root = project("codegen-write")?;
320 let report = codegen_project(&root, CodegenMode::Write)?;
321
322 assert!(report.written);
323 assert_eq!(report.module_relative, "src/demo_io.gleam");
324 assert_eq!(report.module_path, root.join("src/demo_io.gleam"));
325 assert_eq!(
326 report.schemas,
327 vec![
328 "schemas/input.json".to_owned(),
329 "schemas/output.json".to_owned()
330 ]
331 );
332 let on_disk = fs::read_to_string(&report.module_path)?;
333 assert_eq!(on_disk, report.contents);
334 assert!(on_disk.starts_with(
335 "//// Generated by aion codegen — do not edit; regenerate from schemas/."
336 ));
337 assert!(on_disk.contains("pub type Input {"));
338 assert!(on_disk.contains("pub fn output_decoder() -> decode.Decoder(String) {"));
339 fs::remove_dir_all(&root)?;
340 Ok(())
341 }
342
343 #[test]
344 fn generation_is_deterministic_across_runs() -> TestResult {
345 let root = project("codegen-deterministic")?;
346 let first = codegen_project(&root, CodegenMode::Write)?;
347 let first_bytes = fs::read(&first.module_path)?;
348 let second = codegen_project(&root, CodegenMode::Write)?;
349 let second_bytes = fs::read(&second.module_path)?;
350
351 assert_eq!(first.contents, second.contents);
352 assert_eq!(
353 first_bytes, second_bytes,
354 "regeneration must be byte-identical"
355 );
356 fs::remove_dir_all(&root)?;
357 Ok(())
358 }
359
360 #[test]
361 fn check_mode_passes_clean_and_fails_on_drift_naming_the_file() -> TestResult {
362 let root = project("codegen-check")?;
363 let written = codegen_project(&root, CodegenMode::Write)?;
364
365 let checked = codegen_project(&root, CodegenMode::Check)?;
366 assert!(!checked.written);
367 assert_eq!(checked.contents, written.contents);
368
369 let mut perturbed = fs::read_to_string(&written.module_path)?;
370 perturbed.push_str("\n// hand edit\n");
371 fs::write(&written.module_path, &perturbed)?;
372 let result = codegen_project(&root, CodegenMode::Check);
373 let Err(CodegenError::CheckDrift { path }) = result else {
374 return Err(format!("expected CheckDrift, got {result:?}").into());
375 };
376 assert_eq!(path, written.module_path);
377 fs::remove_dir_all(&root)?;
378 Ok(())
379 }
380
381 #[test]
382 fn check_mode_fails_when_the_module_is_missing() -> TestResult {
383 let root = project("codegen-check-missing")?;
384
385 let result = codegen_project(&root, CodegenMode::Check);
386 let Err(CodegenError::CheckMissing { path }) = result else {
387 return Err(format!("expected CheckMissing, got {result:?}").into());
388 };
389 assert_eq!(path, root.join("src/demo_io.gleam"));
390 fs::remove_dir_all(&root)?;
391 Ok(())
392 }
393
394 #[test]
395 fn missing_referenced_schema_fails_through_descriptor_validation() -> TestResult {
396 let root = fixture::temp_project(
397 "codegen-missing-ref",
398 &[
399 ("gleam.toml", GLEAM_TOML.as_bytes()),
400 ("workflow.toml", WORKFLOW_TOML.as_bytes()),
401 ("schemas/output.json", OUTPUT_SCHEMA),
402 ],
403 )?;
404
405 let result = codegen_project(&root, CodegenMode::Write);
406 assert!(
407 matches!(
408 result,
409 Err(CodegenError::Config(PackagingError::SchemaRead { ref path, .. }))
410 if *path == root.join("schemas/input.json")
411 ),
412 "missing referenced schema must fail: {result:?}"
413 );
414 fs::remove_dir_all(&root)?;
415 Ok(())
416 }
417
418 #[test]
419 fn referenced_schema_outside_schemas_dir_is_rejected() -> TestResult {
420 let descriptor = WORKFLOW_TOML.replace("schemas/input.json", "io/input.json");
421 let root = fixture::temp_project(
422 "codegen-outside",
423 &[
424 ("gleam.toml", GLEAM_TOML.as_bytes()),
425 ("workflow.toml", descriptor.as_bytes()),
426 ("io/input.json", INPUT_SCHEMA),
427 ("schemas/output.json", OUTPUT_SCHEMA),
428 ],
429 )?;
430
431 let result = codegen_project(&root, CodegenMode::Write);
432 let Err(CodegenError::SchemaOutsideSchemasDir { field, path }) = result else {
433 return Err(format!("expected SchemaOutsideSchemasDir, got {result:?}").into());
434 };
435 assert_eq!(field, "workflow[0].input_schema");
436 assert_eq!(path, root.join("io/input.json"));
437 fs::remove_dir_all(&root)?;
438 Ok(())
439 }
440
441 #[test]
442 fn unsupported_construct_aborts_before_any_write() -> TestResult {
443 let root = project("codegen-no-partial")?;
444 fixture::write_file(
445 &root,
446 "schemas/zz_tagged.json",
447 br#"{ "oneOf": [ { "type": "object", "properties": {} } ] }"#,
448 )?;
449
450 let result = codegen_project(&root, CodegenMode::Write);
451 let Err(CodegenError::UnsupportedConstruct { file, pointer, .. }) = result else {
452 return Err(format!("expected UnsupportedConstruct, got {result:?}").into());
453 };
454 assert_eq!(file, Path::new("schemas/zz_tagged.json"));
455 assert_eq!(pointer, "/oneOf");
456 assert!(
457 !root.join("src/demo_io.gleam").exists(),
458 "a failed run must not leave a partial module behind"
459 );
460 fs::remove_dir_all(&root)?;
461 Ok(())
462 }
463
464 #[test]
465 fn non_json_entries_in_schemas_are_not_generated_from() -> TestResult {
466 let root = project("codegen-non-json")?;
467 fixture::write_file(&root, "schemas/README.md", b"docs, not a schema")?;
468 fixture::write_file(
469 &root,
470 "schemas/nested/extra.json",
471 br#"{ "type": "string" }"#,
472 )?;
473
474 let report = codegen_project(&root, CodegenMode::Write)?;
475 assert_eq!(
476 report.schemas,
477 vec![
478 "schemas/input.json".to_owned(),
479 "schemas/output.json".to_owned()
480 ]
481 );
482 fs::remove_dir_all(&root)?;
483 Ok(())
484 }
485
486 #[test]
487 fn schemas_dir_listing_errors_are_typed() -> TestResult {
488 let missing = std::env::temp_dir().join("aion-codegen-no-such-dir");
489 let result = list_schema_file_names(&missing);
490 assert!(matches!(
491 result,
492 Err(CodegenError::SchemasDirMissing { ref path }) if *path == missing
493 ));
494
495 let empty = fixture::temp_project("codegen-empty-schemas", &[("schemas/.keep", b"")])?;
496 let result = list_schema_file_names(&empty.join("schemas"));
497 assert!(matches!(result, Err(CodegenError::SchemasDirEmpty { .. })));
498 fs::remove_dir_all(&empty)?;
499 Ok(())
500 }
501
502 #[test]
503 fn gleam_toml_problems_are_typed() -> TestResult {
504 let root = fixture::temp_project(
505 "codegen-no-gleam-toml",
506 &[("workflow.toml", WORKFLOW_TOML.as_bytes())],
507 )?;
508 let result = codegen_project(&root, CodegenMode::Write);
509 assert!(matches!(
510 result,
511 Err(CodegenError::Config(
512 PackagingError::GleamTomlMissing { .. }
513 ))
514 ));
515 fs::remove_dir_all(&root)?;
516
517 let bad_name = fixture::temp_project(
518 "codegen-bad-name",
519 &[
520 ("gleam.toml", b"name = \"Demo-App\"\n"),
521 ("workflow.toml", WORKFLOW_TOML.as_bytes()),
522 ("schemas/input.json", INPUT_SCHEMA),
523 ("schemas/output.json", OUTPUT_SCHEMA),
524 ],
525 )?;
526 let result = codegen_project(&bad_name, CodegenMode::Write);
527 assert!(matches!(
528 result,
529 Err(CodegenError::ProjectName { ref name, .. }) if name == "Demo-App"
530 ));
531 fs::remove_dir_all(&bad_name)?;
532 Ok(())
533 }
534
535 #[test]
541 fn schema_outside_subset_hits_the_loud_error() -> TestResult {
542 const FACTORED_WORKFLOW_TOML: &str = r#"[[workflow]]
543entry_module = "demo"
544entry_function = "run"
545timeout_seconds = 30
546input_schema = "schemas/factored.json"
547output_schema = "schemas/output.json"
548activities = []
549"#;
550 const FACTORED_SCHEMA: &[u8] = br##"{
551 "type": "object",
552 "properties": { "workspace": { "$ref": "#/$defs/workspace" } },
553 "$defs": { "workspace": { "type": "object", "properties": {} } }
554}"##;
555 let root = fixture::temp_project(
556 "codegen-outside-subset",
557 &[
558 ("gleam.toml", GLEAM_TOML.as_bytes()),
559 ("workflow.toml", FACTORED_WORKFLOW_TOML.as_bytes()),
560 ("schemas/factored.json", FACTORED_SCHEMA),
561 ("schemas/output.json", OUTPUT_SCHEMA),
562 ],
563 )?;
564
565 let result = codegen_project(&root, CodegenMode::Check);
566 let Err(CodegenError::UnsupportedConstruct {
567 file,
568 pointer,
569 construct,
570 }) = result
571 else {
572 return Err(format!("expected UnsupportedConstruct, got {result:?}").into());
573 };
574 assert_eq!(file, Path::new("schemas/factored.json"));
575 assert_eq!(pointer, "/$defs");
576 assert!(construct.contains("unrecognised keyword `$defs`"));
577 fs::remove_dir_all(&root)?;
578 Ok(())
579 }
580}