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 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}