Skip to main content

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}