Skip to main content

aion_package/codegen/
declaration.rs

1//! Parsed activity declarations: the typed Gleam source, read as JSON.
2//!
3//! The typed Gleam activity declaration is the single source of truth
4//! (ADR-014). Because a Rust generator cannot read Gleam types, `aion generate`
5//! runs the package's `manifest()` export (via the `gleam` toolchain, in the
6//! CLI — this library never spawns a process) and feeds the canonical JSON it
7//! prints to [`parse_declarations`]. The result drives the activity-plumbing
8//! codegen the way parsed schemas drive codec codegen.
9//!
10//! This module is pure: it validates the declaration list (name safety,
11//! uniqueness, known tier) and preserves declaration order, which is
12//! load-bearing — it fixes the order of generated wrappers, registration
13//! entries, and the `workflow.toml` activities list, so a byte-identical
14//! round-trip depends on it. Resolving a declared value type to its schema is
15//! deferred to generation.
16
17use std::collections::HashSet;
18
19use serde::Deserialize;
20
21use super::error::CodegenError;
22use super::names::{is_reserved_word, is_snake_identifier};
23
24/// Where an activity's side-effecting body executes.
25///
26/// Mirrors the Gleam `activity.Tier`. The generator reads it to choose the
27/// worker handler stub and registration entry to emit, and whether a
28/// wire-compat golden is generated (remote tiers only).
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Tier {
31    /// Runs in-process inside the BEAM VM via a registered NIF.
32    InVm,
33    /// Runs in a remote Python worker over the worker protocol.
34    RemotePython,
35    /// Runs in a remote Rust worker over the worker protocol.
36    RemoteRust,
37}
38
39impl Tier {
40    /// Parses the canonical wire string emitted by Gleam `tier_to_string`.
41    fn from_wire(value: &str) -> Option<Self> {
42        match value {
43            "in_vm" => Some(Self::InVm),
44            "remote_python" => Some(Self::RemotePython),
45            "remote_rust" => Some(Self::RemoteRust),
46            _ => None,
47        }
48    }
49
50    /// Whether the activity's body lives in a remote worker (so a wire-compat
51    /// golden is generated for it).
52    #[must_use]
53    pub fn is_remote(self) -> bool {
54        matches!(self, Self::RemotePython | Self::RemoteRust)
55    }
56}
57
58/// A validated activity declaration: the per-activity facts the generator needs.
59///
60/// `input_type` and `output_type` are the value type names the author wrote in
61/// the Gleam declaration (for example `OrderInput`); generation resolves each to
62/// the `schemas/*.json` document whose generated type carries that name.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ActivityDeclaration {
65    /// The engine-facing activity name.
66    pub name: String,
67    /// The tier the activity runs on.
68    pub tier: Tier,
69    /// The input value type name.
70    pub input_type: String,
71    /// The output value type name.
72    pub output_type: String,
73}
74
75/// One declaration as it appears in the manifest JSON. Unknown fields are
76/// rejected so a typo in the wire contract fails loudly rather than silently
77/// dropping data.
78#[derive(Deserialize)]
79#[serde(deny_unknown_fields)]
80struct RawDeclaration {
81    name: String,
82    tier: String,
83    input: String,
84    output: String,
85}
86
87/// Parses and validates the canonical activity-manifest JSON.
88///
89/// # Errors
90///
91/// Returns [`CodegenError::ManifestParse`] if the bytes are not a JSON array of
92/// declaration objects, [`CodegenError::InvalidActivityName`] for a name that
93/// cannot name an engine activity, [`CodegenError::DuplicateActivity`] for a
94/// repeated name, and [`CodegenError::UnknownTier`] for an unrecognised tier.
95/// Declaration order is preserved.
96pub fn parse_declarations(json: &[u8]) -> Result<Vec<ActivityDeclaration>, CodegenError> {
97    let raw: Vec<RawDeclaration> =
98        serde_json::from_slice(json).map_err(|source| CodegenError::ManifestParse { source })?;
99
100    let mut declarations = Vec::with_capacity(raw.len());
101    let mut seen: HashSet<&str> = HashSet::with_capacity(raw.len());
102    for entry in &raw {
103        if !is_snake_identifier(&entry.name) || is_reserved_word(&entry.name) {
104            return Err(CodegenError::InvalidActivityName {
105                name: entry.name.clone(),
106                reason: "must be a snake_case identifier (a lowercase letter followed by \
107                         lowercase letters, digits, or underscores) and not a Gleam reserved \
108                         word, so it can name the generated wrapper function and worker handler"
109                    .to_owned(),
110            });
111        }
112        if !seen.insert(entry.name.as_str()) {
113            return Err(CodegenError::DuplicateActivity {
114                name: entry.name.clone(),
115            });
116        }
117        let tier = Tier::from_wire(&entry.tier).ok_or_else(|| CodegenError::UnknownTier {
118            value: entry.tier.clone(),
119        })?;
120        declarations.push(ActivityDeclaration {
121            name: entry.name.clone(),
122            tier,
123            input_type: entry.input.clone(),
124            output_type: entry.output.clone(),
125        });
126    }
127    Ok(declarations)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::{Tier, parse_declarations};
133    use crate::codegen::error::CodegenError;
134
135    const WIRE: &[u8] = br#"[
136        {"name":"reserve_inventory","tier":"remote_python","input":"OrderInput","output":"InventoryReservation"},
137        {"name":"ship_order","tier":"in_vm","input":"OrderInput","output":"Shipment"}
138    ]"#;
139
140    #[test]
141    fn parses_in_declaration_order() -> Result<(), CodegenError> {
142        let declarations = parse_declarations(WIRE)?;
143        assert_eq!(declarations.len(), 2);
144        assert_eq!(declarations[0].name, "reserve_inventory");
145        assert_eq!(declarations[0].tier, Tier::RemotePython);
146        assert!(declarations[0].tier.is_remote());
147        assert_eq!(declarations[0].input_type, "OrderInput");
148        assert_eq!(declarations[0].output_type, "InventoryReservation");
149        assert_eq!(declarations[1].name, "ship_order");
150        assert_eq!(declarations[1].tier, Tier::InVm);
151        assert!(!declarations[1].tier.is_remote());
152        Ok(())
153    }
154
155    #[test]
156    fn empty_manifest_parses_to_no_declarations() -> Result<(), CodegenError> {
157        assert!(parse_declarations(b"[]")?.is_empty());
158        Ok(())
159    }
160
161    #[test]
162    fn unknown_tier_is_rejected() {
163        let json = br#"[{"name":"x","tier":"remote_go","input":"A","output":"B"}]"#;
164        assert!(matches!(
165            parse_declarations(json),
166            Err(CodegenError::UnknownTier { value }) if value == "remote_go"
167        ));
168    }
169
170    #[test]
171    fn duplicate_activity_is_rejected() {
172        let json = br#"[
173            {"name":"x","tier":"in_vm","input":"A","output":"B"},
174            {"name":"x","tier":"in_vm","input":"A","output":"B"}
175        ]"#;
176        assert!(matches!(
177            parse_declarations(json),
178            Err(CodegenError::DuplicateActivity { name }) if name == "x"
179        ));
180    }
181
182    #[test]
183    fn names_that_cannot_derive_an_identifier_are_rejected() {
184        // The activity name becomes the generated Gleam wrapper function and the
185        // worker handler, so anything that is not a snake_case identifier — a
186        // path separator, an uppercase letter, a hyphen, a leading digit — or a
187        // Gleam reserved word must fail loudly here, not as a later build error.
188        for bad in [
189            "a/b",
190            "ReserveInventory",
191            "reserve-inventory",
192            "1st",
193            "type",
194            "",
195        ] {
196            let json = format!(r#"[{{"name":"{bad}","tier":"in_vm","input":"A","output":"B"}}]"#);
197            assert!(
198                matches!(
199                    parse_declarations(json.as_bytes()),
200                    Err(CodegenError::InvalidActivityName { name, .. }) if name == bad
201                ),
202                "expected InvalidActivityName for `{bad}`"
203            );
204        }
205    }
206
207    #[test]
208    fn well_formed_snake_case_names_are_accepted() -> Result<(), CodegenError> {
209        let json = br#"[{"name":"reserve_inventory_v2","tier":"in_vm","input":"A","output":"B"}]"#;
210        assert_eq!(parse_declarations(json)?[0].name, "reserve_inventory_v2");
211        Ok(())
212    }
213
214    #[test]
215    fn malformed_json_is_rejected() {
216        assert!(matches!(
217            parse_declarations(b"not json"),
218            Err(CodegenError::ManifestParse { .. })
219        ));
220    }
221}