Skip to main content

aion_package/project/
assemble.rs

1//! `package_project` pipeline: config → discovery → build → verify-after-write.
2
3use std::{
4    collections::BTreeMap,
5    path::{Path, PathBuf},
6};
7
8use serde::Serialize;
9
10use super::{
11    config::{self, WorkflowConfig},
12    discover,
13    error::PackagingError,
14};
15use crate::{
16    BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion, Package,
17    PackageBuilder, WorkflowVersion,
18};
19
20/// Options for packaging an already-built Gleam workflow project.
21///
22/// Construct via [`Default`] and assign fields, so call sites keep compiling
23/// when options are added.
24#[derive(Clone, Debug, Default)]
25pub struct PackageOptions {
26    /// Overrides the single workflow's output path, resolved against the
27    /// project root when relative. Packaging fails with
28    /// [`PackagingError::OutputOverrideAmbiguous`] when the project declares
29    /// more than one workflow.
30    ///
31    /// This is the caller's own path and is intentionally exempt from the
32    /// root confinement applied to `workflow.toml`-declared paths: it may
33    /// point anywhere, including outside the project root (the CLI resolves
34    /// `--out` against the invoker's working directory before passing it
35    /// here).
36    pub output_override: Option<PathBuf>,
37}
38
39/// Result of packaging every workflow a project declares.
40#[derive(Clone, Debug, PartialEq)]
41pub struct ProjectReport {
42    /// One built package per `[[workflow]]` entry, in declaration order.
43    pub packages: Vec<PackagedWorkflow>,
44    /// Modules excluded by the SDK test filter or the dependency-closure filter.
45    pub excluded: Vec<ExcludedModule>,
46}
47
48/// One workflow archive written and verified by [`package_project`].
49#[derive(Clone, Debug, PartialEq)]
50pub struct PackagedWorkflow {
51    /// Workflow type, identical to the manifest entry module.
52    pub workflow_type: String,
53    /// Absolute path of the written `.aion` archive.
54    pub output_path: PathBuf,
55    /// The archive re-loaded from disk after writing, proving integrity.
56    pub package: Package,
57    /// Canonical version record of the verified package.
58    pub version: WorkflowVersion,
59}
60
61/// A compiled module excluded from packaging, with provenance.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
63pub struct ExcludedModule {
64    /// Logical module name that was excluded.
65    pub module: String,
66    /// Gleam package whose ebin provided the module.
67    pub package: String,
68    /// Why the module was excluded.
69    pub reason: ExcludedReason,
70}
71
72/// Reason a discovered compiled module was excluded from packaging.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ExcludedReason {
76    /// SDK test machinery from the `aion_flow` package's ebin.
77    SdkTestOnly,
78    /// Module of a package outside the production dependency closure.
79    DevDependency,
80}
81
82/// Packages every workflow declared by `<root>/workflow.toml`.
83///
84/// The project must already be built (`gleam build`); this function never
85/// spawns processes. The pipeline parses and validates the descriptor,
86/// discovers the production-closure compiled modules once, then writes one
87/// deterministic `.aion` archive per `[[workflow]]` entry. Every written
88/// archive is re-loaded through [`Package::load_from_path`] before this
89/// function returns, so the full read-path validation (integrity hash, format
90/// version, entry module) gates success.
91///
92/// All archives from one project share a single content hash (it covers beams
93/// only), while deployed entry names remain distinct per entry module. First
94/// party sources ship by default and never affect the hash.
95///
96/// Pure with respect to the environment: reads only under `root` (which is
97/// made absolute against the current directory once, up front), writes only
98/// the declared outputs, reads no environment variables, never prints, and
99/// blocks on synchronous filesystem I/O — async callers should wrap it in a
100/// blocking task.
101///
102/// The confinement is enforced, not assumed: every `workflow.toml`-declared
103/// path (`output`, `input_schema`, `output_schema`) is lexically normalized
104/// and must resolve inside `root` — absolute paths and `..` traversal that
105/// escapes the root fail with [`PackagingError::PathEscapesRoot`] before any
106/// file is touched. The sole exception is
107/// [`PackageOptions::output_override`]: that path belongs to the caller and
108/// may point anywhere, including outside the root.
109///
110/// # Errors
111///
112/// Returns [`PackagingError`] variants for missing or invalid `workflow.toml`
113/// descriptors, descriptor paths that are absolute or escape the project
114/// root, unreadable or non-JSON schema files, unbuilt projects, broken Gleam
115/// metadata, unresolved dependencies, duplicate or unreadable compiled
116/// modules, missing entry modules, output conflicts, ambiguous output
117/// overrides, and archive write (path-carrying) or verify-after-write
118/// failures.
119pub fn package_project(
120    root: &Path,
121    options: &PackageOptions,
122) -> Result<ProjectReport, PackagingError> {
123    let root = std::path::absolute(root).map_err(|source| PackagingError::ConfigRead {
124        path: root.to_path_buf(),
125        source,
126    })?;
127
128    let mut config = config::load_config(&root)?;
129    apply_output_override(&root, options, &mut config.workflows)?;
130
131    let discovered = discover::discover_modules(&root)?;
132    let beams = BeamSet::new(discovered.modules)?;
133    let source = if config.include_source {
134        discover::discover_sources(&root)?
135    } else {
136        BTreeMap::new()
137    };
138
139    let mut packages = Vec::with_capacity(config.workflows.len());
140    for workflow in &config.workflows {
141        if beams.get(&workflow.entry_module).is_none() {
142            return Err(PackagingError::EntryModuleNotFound {
143                module: workflow.entry_module.clone(),
144                searched: discovered.searched.clone(),
145            });
146        }
147        packages.push(build_workflow_package(workflow, &beams, &source)?);
148    }
149
150    Ok(ProjectReport {
151        packages,
152        excluded: discovered.excluded,
153    })
154}
155
156fn apply_output_override(
157    root: &Path,
158    options: &PackageOptions,
159    workflows: &mut [WorkflowConfig],
160) -> Result<(), PackagingError> {
161    let Some(output_override) = &options.output_override else {
162        return Ok(());
163    };
164    match workflows {
165        [workflow] => {
166            workflow.output_path = root.join(output_override);
167            Ok(())
168        }
169        _ => Err(PackagingError::OutputOverrideAmbiguous {
170            count: workflows.len(),
171        }),
172    }
173}
174
175fn build_workflow_package(
176    workflow: &WorkflowConfig,
177    beams: &BeamSet,
178    source: &BTreeMap<String, Vec<u8>>,
179) -> Result<PackagedWorkflow, PackagingError> {
180    let manifest = Manifest {
181        entry_module: workflow.entry_module.clone(),
182        entry_function: workflow.entry_function.clone(),
183        input_schema: workflow.input_schema.clone(),
184        output_schema: workflow.output_schema.clone(),
185        timeout: workflow.timeout,
186        activities: workflow
187            .activities
188            .iter()
189            .map(|activity_type| DeclaredActivity {
190                activity_type: activity_type.clone(),
191            })
192            .collect(),
193        version: ManifestVersion::new("unstamped"),
194        format_version: CURRENT_FORMAT_VERSION,
195    };
196
197    PackageBuilder::with_source(manifest, beams.clone(), source.clone())
198        .write_to_path(&workflow.output_path)
199        .map_err(|source| PackagingError::OutputWrite {
200            path: workflow.output_path.clone(),
201            source,
202        })?;
203    let package = Package::load_from_path(&workflow.output_path)?;
204
205    Ok(PackagedWorkflow {
206        workflow_type: workflow.entry_module.clone(),
207        output_path: workflow.output_path.clone(),
208        version: package.version_record(),
209        package,
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use std::{fs, path::PathBuf, time::Duration};
216
217    use serde_json::json;
218
219    use super::{ExcludedModule, ExcludedReason, PackageOptions, package_project};
220    use crate::{PackageError, project::error::PackagingError, project::fixture};
221
222    type TestResult = Result<(), Box<dyn std::error::Error>>;
223
224    const TWO_WORKFLOW_TOML: &str = r#"[[workflow]]
225entry_module = "demo"
226entry_function = "run"
227timeout_seconds = 30
228input_schema = "schemas/input.json"
229output_schema = "schemas/output.json"
230activities = ["greet"]
231
232[[workflow]]
233entry_module = "demo@nested"
234entry_function = "start"
235timeout_seconds = 60
236input_schema = "schemas/input.json"
237output_schema = "schemas/output.json"
238activities = []
239"#;
240
241    #[test]
242    fn packaged_workflow_round_trips_manifest_and_hash() -> TestResult {
243        let root = fixture::synthetic_built_project("assemble-happy")?;
244        let report = package_project(&root, &PackageOptions::default());
245        let reloaded = report
246            .as_ref()
247            .ok()
248            .map(|report| report.packages[0].output_path.clone())
249            .map(crate::Package::load_from_path);
250        fs::remove_dir_all(&root)?;
251        let report = report?;
252
253        assert_eq!(report.packages.len(), 1);
254        let packaged = &report.packages[0];
255        assert_eq!(packaged.workflow_type, "demo");
256        assert!(packaged.output_path.is_absolute());
257        assert_eq!(
258            packaged
259                .output_path
260                .file_name()
261                .and_then(|name| name.to_str()),
262            Some("demo.aion")
263        );
264        let manifest = packaged.package.manifest();
265        assert_eq!(manifest.entry_module, "demo");
266        assert_eq!(manifest.entry_function, "run");
267        assert_eq!(manifest.timeout, Duration::from_secs(30));
268        assert_eq!(manifest.input_schema, json!({ "type": "object" }));
269        assert_eq!(manifest.output_schema, json!(true));
270        assert_eq!(manifest.activities.len(), 1);
271        assert_eq!(manifest.activities[0].activity_type, "greet");
272        assert_eq!(
273            manifest.version.as_str(),
274            packaged.package.content_hash().to_string()
275        );
276        assert_eq!(packaged.version, packaged.package.version_record());
277        let reloaded = reloaded.ok_or("report failed")??;
278        assert_eq!(&reloaded, &packaged.package);
279        Ok(())
280    }
281
282    #[test]
283    fn exclusions_and_sources_are_reported_and_shipped() -> TestResult {
284        let root = fixture::synthetic_built_project("assemble-exclusions")?;
285        let report = package_project(&root, &PackageOptions::default());
286        fs::remove_dir_all(&root)?;
287        let report = report?;
288
289        let expected_exclusions = vec![
290            ExcludedModule {
291                module: "dev_only".to_owned(),
292                package: "dev_only".to_owned(),
293                reason: ExcludedReason::DevDependency,
294            },
295            ExcludedModule {
296                module: "aion@testing".to_owned(),
297                package: "aion_flow".to_owned(),
298                reason: ExcludedReason::SdkTestOnly,
299            },
300            ExcludedModule {
301                module: "aion@testing@mock".to_owned(),
302                package: "aion_flow".to_owned(),
303                reason: ExcludedReason::SdkTestOnly,
304            },
305            ExcludedModule {
306                module: "aion_flow_ffi".to_owned(),
307                package: "aion_flow".to_owned(),
308                reason: ExcludedReason::SdkTestOnly,
309            },
310        ];
311        assert_eq!(report.excluded, expected_exclusions);
312
313        let package = &report.packages[0].package;
314        let source_names: Vec<&str> = package.source().keys().map(String::as_str).collect();
315        assert_eq!(source_names, vec!["demo", "demo/nested"]);
316        let beam_names: Vec<&str> = package
317            .beams()
318            .iter()
319            .map(crate::BeamModule::name)
320            .collect();
321        assert_eq!(
322            beam_names,
323            vec!["aion_flow", "demo", "demo@nested", "dep_a", "dep_b"]
324        );
325        Ok(())
326    }
327
328    #[test]
329    fn missing_entry_module_returns_entry_module_not_found() -> TestResult {
330        let root = fixture::synthetic_built_project("assemble-ghost-entry")?;
331        let descriptor = fixture::DEMO_WORKFLOW_TOML.replace("\"demo\"", "\"ghost\"");
332        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
333        let result = package_project(&root, &PackageOptions::default());
334        fs::remove_dir_all(&root)?;
335
336        assert!(matches!(
337            result,
338            Err(PackagingError::EntryModuleNotFound { module, searched })
339                if module == "ghost" && searched.ends_with("build/dev/erlang")
340        ));
341        Ok(())
342    }
343
344    #[test]
345    fn explicit_output_field_is_respected() -> TestResult {
346        let root = fixture::synthetic_built_project("assemble-explicit-output")?;
347        let descriptor = format!(
348            "{}output = \"custom-name.aion\"\n",
349            fixture::DEMO_WORKFLOW_TOML
350        );
351        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
352        let report = package_project(&root, &PackageOptions::default());
353        let written = root.join("custom-name.aion").is_file();
354        fs::remove_dir_all(&root)?;
355        let report = report?;
356
357        assert!(written);
358        assert_eq!(
359            report.packages[0]
360                .output_path
361                .file_name()
362                .and_then(|name| name.to_str()),
363            Some("custom-name.aion")
364        );
365        Ok(())
366    }
367
368    #[test]
369    fn output_override_applies_to_single_workflow_project() -> TestResult {
370        let root = fixture::synthetic_built_project("assemble-override")?;
371        let options = PackageOptions {
372            output_override: Some(PathBuf::from("override.aion")),
373        };
374        let report = package_project(&root, &options);
375        let written = root.join("override.aion").is_file();
376        let derived_absent = !root.join("demo.aion").exists();
377        fs::remove_dir_all(&root)?;
378        let report = report?;
379
380        assert!(written);
381        assert!(derived_absent);
382        assert_eq!(
383            report.packages[0]
384                .output_path
385                .file_name()
386                .and_then(|name| name.to_str()),
387            Some("override.aion")
388        );
389        Ok(())
390    }
391
392    #[test]
393    fn output_write_failure_names_the_output_path() -> TestResult {
394        let root = fixture::synthetic_built_project("assemble-missing-dir")?;
395        let descriptor = format!(
396            "{}output = \"missing-dir/demo.aion\"\n",
397            fixture::DEMO_WORKFLOW_TOML
398        );
399        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
400        let result = package_project(&root, &PackageOptions::default());
401        fs::remove_dir_all(&root)?;
402
403        let expected = root.join("missing-dir/demo.aion");
404        let Err(error) = result else {
405            return Err("write into a missing directory unexpectedly succeeded".into());
406        };
407        assert!(
408            matches!(
409                &error,
410                PackagingError::OutputWrite { path, .. } if *path == expected
411            ),
412            "error does not carry the output path: {error:?}"
413        );
414        assert!(
415            error.to_string().contains(&expected.display().to_string()),
416            "message does not name the output path: {error}"
417        );
418        Ok(())
419    }
420
421    #[test]
422    fn output_override_may_point_outside_root_via_dotdot() -> TestResult {
423        // The exemption under test: workflow.toml paths are confined to the
424        // root, but the caller's `output_override` may point anywhere.
425        let root = fixture::synthetic_built_project("assemble-override-outside")?;
426        let outside_name = format!("aion-override-outside-{}.aion", std::process::id());
427        let options = PackageOptions {
428            output_override: Some(PathBuf::from(format!("../{outside_name}"))),
429        };
430        let report = package_project(&root, &options);
431        let outside = std::env::temp_dir().join(&outside_name);
432        let written = outside.is_file();
433        fs::remove_dir_all(&root)?;
434        if written {
435            fs::remove_file(&outside)?;
436        }
437        let report = report?;
438
439        assert!(written, "override outside the root was not written");
440        assert_eq!(
441            report.packages[0]
442                .output_path
443                .file_name()
444                .and_then(|name| name.to_str()),
445            Some(outside_name.as_str())
446        );
447        Ok(())
448    }
449
450    #[test]
451    fn output_override_with_multiple_workflows_is_ambiguous() -> TestResult {
452        let root = fixture::synthetic_built_project("assemble-override-multi")?;
453        fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
454        let options = PackageOptions {
455            output_override: Some(PathBuf::from("override.aion")),
456        };
457        let result = package_project(&root, &options);
458        fs::remove_dir_all(&root)?;
459
460        assert!(matches!(
461            result,
462            Err(PackagingError::OutputOverrideAmbiguous { count: 2 })
463        ));
464        Ok(())
465    }
466
467    #[test]
468    fn multi_workflow_project_shares_hash_with_distinct_deployed_entries() -> TestResult {
469        let root = fixture::synthetic_built_project("assemble-multi")?;
470        fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
471        let report = package_project(&root, &PackageOptions::default());
472        fs::remove_dir_all(&root)?;
473        let report = report?;
474
475        assert_eq!(report.packages.len(), 2);
476        let first = &report.packages[0];
477        let second = &report.packages[1];
478        assert_eq!(first.workflow_type, "demo");
479        assert_eq!(second.workflow_type, "demo@nested");
480        assert_eq!(first.package.content_hash(), second.package.content_hash());
481        assert_ne!(
482            first.package.deployed_entry_module(),
483            second.package.deployed_entry_module()
484        );
485        assert_ne!(first.output_path, second.output_path);
486        Ok(())
487    }
488
489    #[test]
490    fn user_module_with_reserved_name_fails_typed() -> TestResult {
491        let root = fixture::synthetic_built_project("assemble-reserved")?;
492        fixture::write_file(
493            &root,
494            "build/dev/erlang/demo/ebin/aion_flow_ffi.beam",
495            b"user-owned-bytes",
496        )?;
497        let result = package_project(&root, &PackageOptions::default());
498        fs::remove_dir_all(&root)?;
499
500        assert!(matches!(
501            result,
502            Err(PackagingError::Package(PackageError::ReservedModuleName { module }))
503                if module == "aion_flow_ffi"
504        ));
505        Ok(())
506    }
507
508    #[test]
509    fn repackaging_produces_identical_archive_bytes() -> TestResult {
510        let root = fixture::synthetic_built_project("assemble-det-1")?;
511        let first_report = package_project(&root, &PackageOptions::default());
512        let first_bytes = fs::read(root.join("demo.aion"));
513        let second_report = package_project(&root, &PackageOptions::default());
514        let second_bytes = fs::read(root.join("demo.aion"));
515        fs::remove_dir_all(&root)?;
516        first_report?;
517        second_report?;
518
519        let first_bytes = first_bytes?;
520        assert!(!first_bytes.is_empty());
521        assert_eq!(first_bytes, second_bytes?);
522        Ok(())
523    }
524
525    #[test]
526    fn source_inclusion_changes_bytes_but_never_the_version() -> TestResult {
527        let root = fixture::synthetic_built_project("assemble-det-3")?;
528        let with_source = package_project(&root, &PackageOptions::default());
529        let with_source_bytes = fs::read(root.join("demo.aion"));
530        let descriptor = format!(
531            "[package]\ninclude_source = false\n\n{}",
532            fixture::DEMO_WORKFLOW_TOML
533        );
534        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
535        let without_source = package_project(&root, &PackageOptions::default());
536        let without_source_bytes = fs::read(root.join("demo.aion"));
537        fs::remove_dir_all(&root)?;
538        let with_source = with_source?;
539        let without_source = without_source?;
540
541        assert!(!with_source.packages[0].package.source().is_empty());
542        assert!(without_source.packages[0].package.source().is_empty());
543        assert_ne!(with_source_bytes?, without_source_bytes?);
544        assert_eq!(
545            with_source.packages[0].package.content_hash(),
546            without_source.packages[0].package.content_hash()
547        );
548        assert_eq!(
549            with_source.packages[0].package.manifest().version,
550            without_source.packages[0].package.manifest().version
551        );
552        Ok(())
553    }
554}