Skip to main content

aion_package/codegen/
project.rs

1//! Project-level codec generation: `workflow.toml` + `schemas/*.json` →
2//! `src/<package>_io.gleam`.
3//!
4//! Generation is deterministic: schema files are processed in filename
5//! (byte) order and property order is preserved from each JSON document, so
6//! the same schemas always produce a byte-identical module. The module is
7//! written only after every schema generated successfully — a loud error
8//! never leaves a partial file behind.
9
10use 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
23/// Directory (relative to the project root) codegen reads schemas from.
24const SCHEMAS_DIR: &str = "schemas";
25
26/// What to do with the generated module.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum CodegenMode {
29    /// Write `src/<package>_io.gleam`, replacing any existing file.
30    Write,
31    /// Compare against the on-disk file and fail on drift without writing
32    /// (CI gate).
33    Check,
34}
35
36/// Result of a successful codegen run.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct CodegenReport {
39    /// Absolute path of the generated module.
40    pub module_path: PathBuf,
41    /// Module path relative to the project root (`src/<package>_io.gleam`).
42    pub module_relative: String,
43    /// Schema files generated from, relative to the project root, in
44    /// generation order.
45    pub schemas: Vec<String>,
46    /// The complete generated module source.
47    pub contents: String,
48    /// Whether the module was written (`false` in check mode).
49    pub written: bool,
50}
51
52/// Generates Gleam types and JSON codecs for every `schemas/*.json` of the
53/// workflow project at `root`, writing or checking
54/// `src/<package>_io.gleam` per `mode`.
55///
56/// The project's `workflow.toml` is validated first (including that every
57/// referenced schema exists, parses, and lives under `schemas/`), so codecs
58/// can never be generated from schemas the packaging boundary would reject.
59///
60/// # Errors
61///
62/// Returns a [`CodegenError`] naming the offending file — and, for schema
63/// constructs outside the supported subset, the JSON pointer — for: invalid
64/// or missing `workflow.toml` / `gleam.toml`, missing or unreadable schema
65/// files, invalid JSON, unsupported schema constructs, generated-name
66/// collisions, write failures, and `--check` drift.
67pub 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
116/// Parses every `schemas/*.json` document under `root` into a deterministic,
117/// filename-ordered artifact list, with names routed through one shared
118/// [`NameRegistry`] so collisions across schemas fail loudly. Both
119/// [`codegen_project`] and the activity generator parse from this single
120/// source so their generated codecs cannot diverge.
121///
122/// # Errors
123///
124/// Returns a [`CodegenError`] for a missing/unreadable `schemas/` directory,
125/// invalid JSON, an unsupported schema construct, or a generated-name
126/// collision — each naming the offending file and JSON pointer.
127pub(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
138/// Lists `*.json` file names directly under `schemas/`, sorted by byte
139/// order. Non-JSON entries and subdirectories are outside the codegen
140/// contract and are not generated from.
141fn 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
183/// Reads, parses (order-preserving), and converts one schema file.
184fn 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
239/// Reads the Gleam package name from `<root>/gleam.toml`; it prefixes the
240/// generated module (`src/<name>_io.gleam`).
241pub(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    /// A schema that factors its shape through `$defs`/`$ref` is outside the v1
536    /// subset and must fail loudly, naming the file and pointer. Built as a
537    /// fixture: every stacked-dev example schema is in-subset since the
538    /// brief-dev migration, so the example no longer carries an out-of-subset
539    /// case to assert against.
540    #[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}