aion_package/project/error.rs
1//! Error taxonomy for project-level workflow packaging.
2
3use std::path::PathBuf;
4
5use crate::PackageError;
6
7/// Errors produced while packaging a built Gleam workflow project.
8///
9/// Every variant carries the offending path, field, or module as structured
10/// data — not just formatted text — so callers can map variants to actionable
11/// guidance (for example, [`PackagingError::ProjectNotBuilt`] maps to "run
12/// `gleam build`").
13#[derive(thiserror::Error, Debug)]
14pub enum PackagingError {
15 /// The project root contains no `workflow.toml` packaging descriptor.
16 #[error("no workflow.toml found in {root}")]
17 ConfigMissing {
18 /// Project root that was searched for the descriptor.
19 root: PathBuf,
20 },
21
22 /// The `workflow.toml` descriptor exists but could not be read.
23 #[error("failed to read {path}: {source}")]
24 ConfigRead {
25 /// Path of the descriptor that could not be read.
26 path: PathBuf,
27 /// I/O failure reported while reading the descriptor.
28 source: std::io::Error,
29 },
30
31 /// The `workflow.toml` descriptor is not valid TOML for the schema.
32 ///
33 /// The wrapped error carries unknown-key detail: every table rejects
34 /// unrecognised keys, naming the key and its location.
35 #[error("failed to parse {path}: {source}")]
36 ConfigParse {
37 /// Path of the descriptor that failed to parse.
38 path: PathBuf,
39 /// TOML deserialisation failure, including unknown-key detail.
40 source: toml::de::Error,
41 },
42
43 /// The `workflow.toml` descriptor parsed but failed semantic validation.
44 #[error("invalid workflow.toml: {field}: {reason}")]
45 ConfigInvalid {
46 /// Descriptor field that failed validation, e.g. `workflow[0].entry_module`.
47 field: String,
48 /// Human-readable reason the field value was rejected.
49 reason: String,
50 },
51
52 /// A declared JSON-Schema file does not exist. Schemas are generated
53 /// artifacts (emitted from the project's types module), so on a fresh
54 /// clone they are absent until the project is generated.
55 #[error(
56 "schema {path} does not exist; schemas/*.json are generated artifacts — \
57 run `aion generate` to emit them from the project's types module"
58 )]
59 SchemaMissing {
60 /// Resolved path of the schema file that was expected.
61 path: PathBuf,
62 },
63
64 /// A declared JSON-Schema file could not be read.
65 #[error("failed to read schema {path}: {source}")]
66 SchemaRead {
67 /// Resolved path of the schema file that could not be read.
68 path: PathBuf,
69 /// I/O failure reported while reading the schema file.
70 source: std::io::Error,
71 },
72
73 /// A declared JSON-Schema file is not valid JSON.
74 #[error("schema {path} is not valid JSON: {source}")]
75 SchemaParse {
76 /// Resolved path of the schema file that failed to parse.
77 path: PathBuf,
78 /// JSON parsing failure reported by `serde_json`.
79 source: serde_json::Error,
80 },
81
82 /// The compiled Erlang output required for packaging does not exist.
83 #[error("project is not built: {missing} does not exist; run `gleam build` first")]
84 ProjectNotBuilt {
85 /// Build-output path that was required but absent.
86 missing: PathBuf,
87 },
88
89 /// The project root contains no `gleam.toml`, so it is not a Gleam project.
90 #[error("not a Gleam project: {path} not found")]
91 GleamTomlMissing {
92 /// Path where `gleam.toml` was expected.
93 path: PathBuf,
94 },
95
96 /// A Gleam metadata file (`gleam.toml` or the `manifest.toml` lockfile)
97 /// could not be read.
98 #[error("failed to read Gleam metadata {path}: {source}")]
99 GleamMetadataRead {
100 /// Path of the metadata file that could not be read.
101 path: PathBuf,
102 /// I/O failure reported while reading the metadata file.
103 source: std::io::Error,
104 },
105
106 /// A Gleam metadata file could not be parsed as the expected TOML shape.
107 #[error("failed to parse Gleam metadata {path}: {source}")]
108 GleamMetadataParse {
109 /// Path of the metadata file that failed to parse.
110 path: PathBuf,
111 /// TOML deserialisation failure reported while parsing the file.
112 source: toml::de::Error,
113 },
114
115 /// A production dependency named in `gleam.toml` is absent from the
116 /// `manifest.toml` lockfile, so the dependency closure cannot be computed.
117 #[error("dependency `{package}` is in gleam.toml but missing from manifest.toml; rebuild")]
118 DependencyUnresolved {
119 /// Gleam package name that could not be resolved in the lockfile.
120 package: String,
121 },
122
123 /// A compiled `.beam` module (or its containing directory) could not be read.
124 #[error("failed to read compiled module {path}: {source}")]
125 BeamRead {
126 /// Path of the compiled module or module directory that failed to read.
127 path: PathBuf,
128 /// I/O failure reported while reading the compiled output.
129 source: std::io::Error,
130 },
131
132 /// A compiled module filename is not valid UTF-8 and cannot become a
133 /// logical module name.
134 #[error("compiled module filename is not valid UTF-8: {path}")]
135 ModuleNameNotUtf8 {
136 /// Path whose filename failed UTF-8 decoding.
137 path: PathBuf,
138 },
139
140 /// Two Gleam packages in the production dependency closure provide the same
141 /// logical module name.
142 #[error("module `{module}` is provided by both `{first}` and `{second}`")]
143 DuplicateModule {
144 /// Logical module name provided more than once.
145 module: String,
146 /// Gleam package that provided the module first.
147 first: String,
148 /// Gleam package that provided the module again.
149 second: String,
150 },
151
152 /// A declared workflow entry module is absent from the compiled output.
153 #[error("entry module `{module}` not found in compiled output under {searched}")]
154 EntryModuleNotFound {
155 /// Entry module declared by `workflow.toml`.
156 module: String,
157 /// Compiled-output directory that was searched.
158 searched: PathBuf,
159 },
160
161 /// A first-party Gleam source file could not be read for inclusion.
162 #[error("failed to read source file {path}: {source}")]
163 SourceRead {
164 /// Path of the source file or directory that could not be read.
165 path: PathBuf,
166 /// I/O failure reported while reading the source tree.
167 source: std::io::Error,
168 },
169
170 /// A `workflow.toml`-declared path is absolute or escapes the project
171 /// root after lexically folding `.` and `..` components.
172 ///
173 /// Only descriptor-sourced paths (`output`, `input_schema`,
174 /// `output_schema`) are confined to the root; the programmatic
175 /// [`PackageOptions::output_override`](crate::PackageOptions) is the
176 /// caller's own path and is intentionally exempt.
177 #[error("invalid workflow.toml: {field}: path {path} is absolute or escapes the project root")]
178 PathEscapesRoot {
179 /// Descriptor field that declared the path, e.g. `workflow[0].output`.
180 field: String,
181 /// The offending path exactly as declared in the descriptor.
182 path: PathBuf,
183 },
184
185 /// Two workflows resolve to the same output archive path.
186 #[error("workflows `{first}` and `{second}` both write to {path}")]
187 OutputConflict {
188 /// Entry module of the workflow that claimed the path first.
189 first: String,
190 /// Entry module of the workflow that claimed the path again.
191 second: String,
192 /// Output path claimed by both workflows.
193 path: PathBuf,
194 },
195
196 /// An output override was supplied for a project declaring multiple workflows.
197 #[error("--out is only valid for single-workflow projects ({count} declared)")]
198 OutputOverrideAmbiguous {
199 /// Number of workflows the project declares.
200 count: usize,
201 },
202
203 /// A workflow's `.aion` archive could not be built or written to its
204 /// resolved output path.
205 ///
206 /// Unlike the transparent [`PackagingError::Package`] variant, this one
207 /// names the output path, so "No such file or directory"-style I/O
208 /// failures identify the file that could not be written.
209 #[error("failed to write archive {path}: {source}")]
210 OutputWrite {
211 /// Resolved output path the archive could not be written to.
212 path: PathBuf,
213 /// Package-format failure reported while building or writing.
214 source: PackageError,
215 },
216
217 /// A package-format failure surfaced while building, writing, or re-loading
218 /// an archive (reserved module names, write I/O, verify-after-write).
219 #[error(transparent)]
220 Package(#[from] PackageError),
221}
222
223#[cfg(test)]
224mod tests {
225 use std::path::PathBuf;
226
227 use super::PackagingError;
228
229 fn assert_send_sync<T: Send + Sync + 'static>() {}
230
231 #[test]
232 fn packaging_error_is_send_sync_and_static() {
233 assert_send_sync::<PackagingError>();
234 }
235
236 #[test]
237 fn display_messages_name_the_failed_condition() {
238 assert_eq!(
239 PackagingError::ConfigMissing {
240 root: PathBuf::from("/project"),
241 }
242 .to_string(),
243 "no workflow.toml found in /project"
244 );
245 assert_eq!(
246 PackagingError::ConfigInvalid {
247 field: "workflow[0].timeout_seconds".to_owned(),
248 reason: "must be at least 1".to_owned(),
249 }
250 .to_string(),
251 "invalid workflow.toml: workflow[0].timeout_seconds: must be at least 1"
252 );
253 assert_eq!(
254 PackagingError::ProjectNotBuilt {
255 missing: PathBuf::from("/project/build/dev/erlang"),
256 }
257 .to_string(),
258 "project is not built: /project/build/dev/erlang does not exist; \
259 run `gleam build` first"
260 );
261 assert_eq!(
262 PackagingError::DependencyUnresolved {
263 package: "gleam_json".to_owned(),
264 }
265 .to_string(),
266 "dependency `gleam_json` is in gleam.toml but missing from manifest.toml; rebuild"
267 );
268 assert_eq!(
269 PackagingError::DuplicateModule {
270 module: "shared".to_owned(),
271 first: "pkg_a".to_owned(),
272 second: "pkg_b".to_owned(),
273 }
274 .to_string(),
275 "module `shared` is provided by both `pkg_a` and `pkg_b`"
276 );
277 assert_eq!(
278 PackagingError::EntryModuleNotFound {
279 module: "ghost".to_owned(),
280 searched: PathBuf::from("/project/build/dev/erlang"),
281 }
282 .to_string(),
283 "entry module `ghost` not found in compiled output under /project/build/dev/erlang"
284 );
285 assert_eq!(
286 PackagingError::OutputConflict {
287 first: "alpha".to_owned(),
288 second: "beta".to_owned(),
289 path: PathBuf::from("/project/alpha.aion"),
290 }
291 .to_string(),
292 "workflows `alpha` and `beta` both write to /project/alpha.aion"
293 );
294 assert_eq!(
295 PackagingError::OutputOverrideAmbiguous { count: 3 }.to_string(),
296 "--out is only valid for single-workflow projects (3 declared)"
297 );
298 assert_eq!(
299 PackagingError::PathEscapesRoot {
300 field: "workflow[0].output".to_owned(),
301 path: PathBuf::from("../escape.aion"),
302 }
303 .to_string(),
304 "invalid workflow.toml: workflow[0].output: path ../escape.aion \
305 is absolute or escapes the project root"
306 );
307 let output_write = PackagingError::OutputWrite {
308 path: PathBuf::from("/project/missing/demo.aion"),
309 source: crate::PackageError::ArchiveWriteIo {
310 source: std::io::Error::from(std::io::ErrorKind::NotFound),
311 },
312 };
313 assert!(
314 output_write
315 .to_string()
316 .starts_with("failed to write archive /project/missing/demo.aion: ")
317 );
318 assert!(std::error::Error::source(&output_write).is_some());
319 }
320
321 #[test]
322 fn package_error_converts_transparently() {
323 let error = PackagingError::from(crate::PackageError::MissingManifest);
324
325 assert_eq!(error.to_string(), "missing required manifest.json entry");
326 assert!(matches!(error, PackagingError::Package(_)));
327 }
328}