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