Skip to main content

aion_package/codegen/
project.rs

1//! Project-level codegen plumbing shared by every generator: the write/check
2//! mode, the byte-exact `--check` comparison, and the package-name read.
3//!
4//! The schema-first front door (`codegen_project`, which read authored
5//! `schemas/*.json`) is gone: the authored source of truth is the Gleam types
6//! module `src/<package>_io.gleam` (ADR-014, resolved types-first 2026-07-02),
7//! mapped into the model by [`super::interface`], and `schemas/*.json` is now
8//! an emitted artifact owned by [`super::schema_emit`].
9
10use std::io;
11use std::path::Path;
12
13use serde::Deserialize;
14
15use super::error::CodegenError;
16use super::names::{is_reserved_word, is_snake_identifier};
17use crate::PackagingError;
18
19/// What to do with generated output.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum CodegenMode {
22    /// Write every generated file, replacing existing ones.
23    Write,
24    /// Compare against the on-disk files and fail on drift without writing
25    /// (CI gate).
26    Check,
27}
28
29/// Byte-compares a generated file's fresh contents against the on-disk file,
30/// failing with a typed error when the file is missing, unreadable, or
31/// drifted.
32pub(crate) fn check_on_disk(module_path: &Path, contents: &str) -> Result<(), CodegenError> {
33    let on_disk = match std::fs::read(module_path) {
34        Ok(bytes) => bytes,
35        Err(source) if source.kind() == io::ErrorKind::NotFound => {
36            return Err(CodegenError::CheckMissing {
37                path: module_path.to_path_buf(),
38            });
39        }
40        Err(source) => {
41            return Err(CodegenError::CheckRead {
42                path: module_path.to_path_buf(),
43                source,
44            });
45        }
46    };
47    if on_disk != contents.as_bytes() {
48        return Err(CodegenError::CheckDrift {
49            path: module_path.to_path_buf(),
50        });
51    }
52    Ok(())
53}
54
55#[derive(Debug, Deserialize)]
56struct GleamTomlName {
57    name: String,
58}
59
60/// Reads the Gleam package name from `<root>/gleam.toml`; it prefixes every
61/// generated module (`src/<name>_codecs.gleam`) and names the authored types
62/// module (`src/<name>_io.gleam`).
63pub(crate) fn read_package_name(root: &Path) -> Result<String, CodegenError> {
64    let path = root.join("gleam.toml");
65    let text = match std::fs::read_to_string(&path) {
66        Ok(text) => text,
67        Err(source) if source.kind() == io::ErrorKind::NotFound => {
68            return Err(CodegenError::Config(PackagingError::GleamTomlMissing {
69                path,
70            }));
71        }
72        Err(source) => {
73            return Err(CodegenError::Config(PackagingError::GleamMetadataRead {
74                path,
75                source,
76            }));
77        }
78    };
79    let parsed: GleamTomlName = toml::from_str(&text).map_err(|source| {
80        CodegenError::Config(PackagingError::GleamMetadataParse { path, source })
81    })?;
82    if !is_snake_identifier(&parsed.name) || is_reserved_word(&parsed.name) {
83        return Err(CodegenError::ProjectName {
84            name: parsed.name,
85            reason: "must be a snake_case identifier and not a Gleam reserved word".to_owned(),
86        });
87    }
88    Ok(parsed.name)
89}
90
91#[cfg(test)]
92mod tests {
93    use std::fs;
94
95    use super::{check_on_disk, read_package_name};
96    use crate::PackagingError;
97    use crate::codegen::error::CodegenError;
98    use crate::project::fixture;
99
100    type TestResult = Result<(), Box<dyn std::error::Error>>;
101
102    #[test]
103    fn check_on_disk_passes_clean_and_types_missing_and_drift() -> TestResult {
104        let root = fixture::temp_project(
105            "codegen-check-on-disk",
106            &[("src/demo_codecs.gleam", b"generated contents\n" as &[u8])],
107        )?;
108        let path = root.join("src/demo_codecs.gleam");
109
110        check_on_disk(&path, "generated contents\n")?;
111
112        let drift = check_on_disk(&path, "other contents\n");
113        assert!(matches!(
114            drift,
115            Err(CodegenError::CheckDrift { path: ref drifted }) if *drifted == path
116        ));
117
118        let missing_path = root.join("src/absent.gleam");
119        let missing = check_on_disk(&missing_path, "anything");
120        assert!(matches!(
121            missing,
122            Err(CodegenError::CheckMissing { path: ref reported }) if *reported == missing_path
123        ));
124        fs::remove_dir_all(&root)?;
125        Ok(())
126    }
127
128    #[test]
129    fn package_name_reads_and_validates() -> TestResult {
130        let root = fixture::temp_project(
131            "codegen-package-name",
132            &[(
133                "gleam.toml",
134                b"name = \"demo\"\nversion = \"0.1.0\"\n" as &[u8],
135            )],
136        )?;
137        assert_eq!(read_package_name(&root)?, "demo");
138        fs::remove_dir_all(&root)?;
139
140        let missing = fixture::temp_project("codegen-package-name-missing", &[])?;
141        let result = read_package_name(&missing);
142        assert!(matches!(
143            result,
144            Err(CodegenError::Config(
145                PackagingError::GleamTomlMissing { .. }
146            ))
147        ));
148        fs::remove_dir_all(&missing)?;
149
150        let bad = fixture::temp_project(
151            "codegen-package-name-bad",
152            &[("gleam.toml", b"name = \"Demo-App\"\n" as &[u8])],
153        )?;
154        let result = read_package_name(&bad);
155        assert!(matches!(
156            result,
157            Err(CodegenError::ProjectName { ref name, .. }) if name == "Demo-App"
158        ));
159        fs::remove_dir_all(&bad)?;
160        Ok(())
161    }
162}