aion_package/codegen/
declaration.rs1use std::collections::HashSet;
18
19use serde::Deserialize;
20
21use super::error::CodegenError;
22use super::names::{is_reserved_word, is_snake_identifier};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Tier {
31 InVm,
33 RemotePython,
35 RemoteRust,
37}
38
39impl Tier {
40 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 #[must_use]
53 pub fn is_remote(self) -> bool {
54 matches!(self, Self::RemotePython | Self::RemoteRust)
55 }
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ActivityDeclaration {
65 pub name: String,
67 pub tier: Tier,
69 pub input_type: String,
71 pub output_type: String,
73}
74
75#[derive(Deserialize)]
79#[serde(deny_unknown_fields)]
80struct RawDeclaration {
81 name: String,
82 tier: String,
83 input: String,
84 output: String,
85}
86
87pub 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 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}