aion_package/codegen/error.rs
1//! Error taxonomy for the types-first codec generation.
2//!
3//! Every variant carries the offending module, type, field, or path as
4//! structured data so callers can render actionable guidance. Subset failures
5//! always name the types module and the type (and field, where one exists) of
6//! the offending construct.
7
8use std::path::PathBuf;
9
10use crate::PackagingError;
11
12/// Errors produced while generating codecs, schemas, and activity plumbing
13/// from a workflow project's authored types module.
14#[derive(thiserror::Error, Debug)]
15pub enum CodegenError {
16 /// The project descriptor (`workflow.toml`) or a schema it references
17 /// failed to load.
18 #[error(transparent)]
19 Config(#[from] PackagingError),
20
21 /// The project's `gleam.toml` declares a package name that cannot prefix
22 /// a generated Gleam module.
23 #[error("gleam.toml package name `{name}` cannot name the generated module: {reason}")]
24 ProjectName {
25 /// The declared package name.
26 name: String,
27 /// Why the name cannot be used.
28 reason: String,
29 },
30
31 /// The `gleam export package-interface` JSON did not parse into the
32 /// expected shape. The format is owned by the Gleam compiler; an unknown
33 /// shape fails loudly rather than being guessed at.
34 #[error(
35 "the exported package interface did not parse: {source}; the interface JSON is \
36 produced by `gleam export package-interface` (check the installed gleam version)"
37 )]
38 InterfaceParse {
39 /// JSON parsing failure reported by `serde_json`.
40 source: serde_json::Error,
41 },
42
43 /// The exported interface has no types module for the package.
44 #[error(
45 "the package exports no `{module}` module; declare the workflow's boundary types in \
46 `src/{module}.gleam` — it is the authored source `aion generate` reads (ADR-014)"
47 )]
48 TypesModuleMissing {
49 /// The expected types-module name (`<package>_io`).
50 module: String,
51 },
52
53 /// The types module exports functions, constants, or type aliases.
54 #[error(
55 "`src/{module}.gleam` must declare types only, but exports: {offenders:?}; codecs are \
56 generated FROM the types (run `aion generate`), never hand-written beside them"
57 )]
58 TypesModuleNotTypesOnly {
59 /// The types-module name.
60 module: String,
61 /// The offending exports (`fn x`, `const y`, `type alias z`).
62 offenders: Vec<String>,
63 },
64
65 /// The types module declares no public types.
66 #[error("`src/{module}.gleam` declares no public types to generate codecs from")]
67 TypesModuleEmpty {
68 /// The types-module name.
69 module: String,
70 },
71
72 /// A type in the types module falls outside the supported v1 subset.
73 #[error(
74 "unsupported boundary type in `{module}`: `{type_name}`{field_part}: found {found}; {hint}",
75 field_part = .field.as_ref().map(|name| format!(", field `{name}`")).unwrap_or_default()
76 )]
77 UnsupportedType {
78 /// The types-module name.
79 module: String,
80 /// The offending type.
81 type_name: String,
82 /// The offending field, when the construct is field-scoped.
83 field: Option<String>,
84 /// What was found.
85 found: String,
86 /// How to fix it.
87 hint: String,
88 },
89
90 /// A hand-authored (unmarked) `*.json` file sits in the `schemas/`
91 /// directory, which holds generated artifacts only.
92 #[error(
93 "{path} is not a generated schema artifact; schemas are emitted from the types module \
94 (ADR-014) and never authored. To migrate a schema-first project: declare the boundary \
95 types in src/<package>_io.gleam, delete the authored schemas/*.json, and run \
96 `aion generate` — the emitted schemas replace them"
97 )]
98 SchemaStray {
99 /// The stray file.
100 path: PathBuf,
101 },
102
103 /// The `schemas/` directory could not be listed.
104 #[error("failed to list schemas directory {path}: {source}")]
105 SchemasDirRead {
106 /// The `schemas/` directory that could not be listed.
107 path: PathBuf,
108 /// I/O failure reported while listing the directory.
109 source: std::io::Error,
110 },
111
112 /// A schema uses a construct outside the supported v1 subset (the `aion
113 /// input` skeleton walks the emitted schema documents).
114 #[error("unsupported JSON Schema construct in {file} at `{pointer}`: {construct}")]
115 UnsupportedConstruct {
116 /// The schema file containing the construct.
117 file: PathBuf,
118 /// JSON pointer to the offending construct (`` `` is the document root).
119 pointer: String,
120 /// What the construct was and, where helpful, what is supported.
121 construct: String,
122 },
123
124 /// A generated file could not be written.
125 #[error("failed to write generated file {path}: {source}")]
126 Write {
127 /// The path that could not be written.
128 path: PathBuf,
129 /// I/O failure reported while writing.
130 source: std::io::Error,
131 },
132
133 /// `--check` failed: a generated file does not exist on disk.
134 #[error("--check failed: generated file {path} does not exist; run `aion generate`")]
135 CheckMissing {
136 /// The expected generated file path.
137 path: PathBuf,
138 },
139
140 /// `--check` failed: an on-disk file differs from the generated output.
141 #[error(
142 "--check failed: {path} differs from the generated output; \
143 run `aion generate` to regenerate it"
144 )]
145 CheckDrift {
146 /// The drifted generated file path.
147 path: PathBuf,
148 },
149
150 /// An on-disk file could not be read for `--check` comparison.
151 #[error("failed to read {path} for --check: {source}")]
152 CheckRead {
153 /// The path that could not be read.
154 path: PathBuf,
155 /// I/O failure reported while reading.
156 source: std::io::Error,
157 },
158
159 /// The activity manifest JSON emitted by the package's `manifest()` export
160 /// is not a valid declaration array.
161 #[error("activity manifest is not valid declaration JSON: {source}")]
162 ManifestParse {
163 /// JSON parsing failure reported by `serde_json`.
164 source: serde_json::Error,
165 },
166
167 /// A declared activity name cannot name an engine activity or a generated
168 /// artifact (empty, or carrying a path separator, backslash, or the
169 /// deployed-name separator).
170 #[error("activity name `{name}` is invalid: {reason}")]
171 InvalidActivityName {
172 /// The offending activity name.
173 name: String,
174 /// Why the name cannot be used.
175 reason: String,
176 },
177
178 /// Two declarations share an activity name; names must be unique within a
179 /// package so the generated wrappers, registration entries, and
180 /// `workflow.toml` list are unambiguous.
181 #[error("activity `{name}` is declared more than once")]
182 DuplicateActivity {
183 /// The duplicated activity name.
184 name: String,
185 },
186
187 /// A declaration carries a tier outside the supported set.
188 #[error("unknown activity tier `{value}`; expected `in_vm`, `remote_python`, or `remote_rust`")]
189 UnknownTier {
190 /// The unrecognised tier string.
191 value: String,
192 },
193
194 /// A declared activity references a value type not declared in the types
195 /// module, so its codec cannot be generated.
196 #[error(
197 "activity `{activity}` {role} type `{type_name}` is not declared in \
198 src/{module}.gleam (codecs are generated from the types module's public types)"
199 )]
200 ActivityTypeMissing {
201 /// The activity whose type is unresolved.
202 activity: String,
203 /// Which side of the activity the type is on (`input` or `output`).
204 role: &'static str,
205 /// The unresolved value type name.
206 type_name: String,
207 /// The types module the type was expected in (`<package>_io`).
208 module: String,
209 },
210
211 /// While deriving a wire-compat golden, a value type referenced a named
212 /// Gleam type absent from its boundary-type definitions. The interface
213 /// front-end collects every referenced definition into the closure, so
214 /// this signals a generator invariant violation rather than bad author
215 /// input.
216 #[error(
217 "internal: wire-compat golden for type `{root_type}` references undefined \
218 named type `{missing}` in {file}"
219 )]
220 GoldenTypeUnresolved {
221 /// The value type whose golden was being derived.
222 root_type: String,
223 /// The named type that could not be found in the definitions.
224 missing: String,
225 /// The emitted schema artifact whose definitions were searched.
226 file: PathBuf,
227 },
228
229 /// The test-scaffold generator could not read the workflow's entry-module
230 /// source, which it needs to derive the typed entry function and timer count.
231 #[error("failed to read workflow entry source {path}: {source}")]
232 EntrySourceRead {
233 /// The entry-module source path that could not be read.
234 path: PathBuf,
235 /// The underlying I/O error.
236 source: std::io::Error,
237 },
238
239 /// The workflow's entry-module source does not yield the facts the test
240 /// scaffold needs (no `aion/workflow` import, no `define` call, or an
241 /// unidentifiable typed entry function).
242 #[error("cannot derive test-scaffold facts from {path}: {reason}")]
243 ScaffoldFacts {
244 /// The entry-module source path the facts were read from.
245 path: PathBuf,
246 /// Why the facts could not be derived.
247 reason: String,
248 },
249}
250
251#[cfg(test)]
252mod tests {
253 use std::path::PathBuf;
254
255 use super::CodegenError;
256
257 fn assert_send_sync<T: Send + Sync + 'static>() {}
258
259 #[test]
260 fn codegen_error_is_send_sync_and_static() {
261 assert_send_sync::<CodegenError>();
262 }
263
264 #[test]
265 fn display_messages_name_module_type_and_field() {
266 assert_eq!(
267 CodegenError::UnsupportedType {
268 module: "demo_io".to_owned(),
269 type_name: "Order".to_owned(),
270 field: Some("total".to_owned()),
271 found: "a tuple type".to_owned(),
272 hint: "use a record".to_owned(),
273 }
274 .to_string(),
275 "unsupported boundary type in `demo_io`: `Order`, field `total`: \
276 found a tuple type; use a record"
277 );
278 assert_eq!(
279 CodegenError::UnsupportedType {
280 module: "demo_io".to_owned(),
281 type_name: "Order".to_owned(),
282 field: None,
283 found: "an opaque type".to_owned(),
284 hint: "expose the constructors".to_owned(),
285 }
286 .to_string(),
287 "unsupported boundary type in `demo_io`: `Order`: found an opaque type; \
288 expose the constructors"
289 );
290 assert_eq!(
291 CodegenError::CheckDrift {
292 path: PathBuf::from("src/demo_codecs.gleam"),
293 }
294 .to_string(),
295 "--check failed: src/demo_codecs.gleam differs from the generated \
296 output; run `aion generate` to regenerate it"
297 );
298 assert_eq!(
299 CodegenError::CheckMissing {
300 path: PathBuf::from("src/demo_codecs.gleam"),
301 }
302 .to_string(),
303 "--check failed: generated file src/demo_codecs.gleam does not exist; \
304 run `aion generate`"
305 );
306 let missing = CodegenError::TypesModuleMissing {
307 module: "demo_io".to_owned(),
308 }
309 .to_string();
310 assert!(missing.contains("src/demo_io.gleam") && missing.contains("ADR-014"));
311 let activity = CodegenError::ActivityTypeMissing {
312 activity: "charge".to_owned(),
313 role: "output",
314 type_name: "Receipt".to_owned(),
315 module: "demo_io".to_owned(),
316 }
317 .to_string();
318 assert!(activity.contains("charge") && activity.contains("src/demo_io.gleam"));
319 }
320
321 #[test]
322 fn packaging_errors_convert_transparently() {
323 let error = CodegenError::from(crate::PackagingError::ConfigMissing {
324 root: PathBuf::from("/project"),
325 });
326
327 assert_eq!(error.to_string(), "no workflow.toml found in /project");
328 assert!(matches!(error, CodegenError::Config(_)));
329 }
330}