1use 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, ExtractionLimits, Manifest, ManifestVersion,
17 Package, PackageBuilder, WorkflowVersion,
18};
19
20#[derive(Clone, Debug, Default)]
25pub struct PackageOptions {
26 pub output_override: Option<PathBuf>,
37}
38
39#[derive(Clone, Debug, PartialEq)]
41pub struct ProjectReport {
42 pub packages: Vec<PackagedWorkflow>,
44 pub excluded: Vec<ExcludedModule>,
46}
47
48#[derive(Clone, Debug, PartialEq)]
50pub struct PackagedWorkflow {
51 pub workflow_type: String,
53 pub output_path: PathBuf,
55 pub package: Package,
57 pub version: WorkflowVersion,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
63pub struct ExcludedModule {
64 pub module: String,
66 pub package: String,
68 pub reason: ExcludedReason,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ExcludedReason {
76 SdkTestOnly,
78 DevDependency,
80}
81
82pub 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, ExtractionLimits::unbounded())?;
206
207 Ok(PackagedWorkflow {
208 workflow_type: workflow.entry_module.clone(),
209 output_path: workflow.output_path.clone(),
210 version: package.version_record(),
211 package,
212 })
213}
214
215#[cfg(test)]
216mod tests {
217 use std::{fs, path::PathBuf, time::Duration};
218
219 use serde_json::json;
220
221 use super::{ExcludedModule, ExcludedReason, PackageOptions, package_project};
222 use crate::{PackageError, project::error::PackagingError, project::fixture};
223
224 type TestResult = Result<(), Box<dyn std::error::Error>>;
225
226 const TWO_WORKFLOW_TOML: &str = r#"[[workflow]]
227entry_module = "demo"
228entry_function = "run"
229timeout_seconds = 30
230input_schema = "schemas/input.json"
231output_schema = "schemas/output.json"
232activities = ["greet"]
233
234[[workflow]]
235entry_module = "demo@nested"
236entry_function = "start"
237timeout_seconds = 60
238input_schema = "schemas/input.json"
239output_schema = "schemas/output.json"
240activities = []
241"#;
242
243 #[test]
244 fn packaged_workflow_round_trips_manifest_and_hash() -> TestResult {
245 let root = fixture::synthetic_built_project("assemble-happy")?;
246 let report = package_project(&root, &PackageOptions::default());
247 let reloaded = report
248 .as_ref()
249 .ok()
250 .map(|report| report.packages[0].output_path.clone())
251 .map(|path| crate::Package::load_from_path(path, crate::ExtractionLimits::unbounded()));
252 fs::remove_dir_all(&root)?;
253 let report = report?;
254
255 assert_eq!(report.packages.len(), 1);
256 let packaged = &report.packages[0];
257 assert_eq!(packaged.workflow_type, "demo");
258 assert!(packaged.output_path.is_absolute());
259 assert_eq!(
260 packaged
261 .output_path
262 .file_name()
263 .and_then(|name| name.to_str()),
264 Some("demo.aion")
265 );
266 let manifest = packaged.package.manifest();
267 assert_eq!(manifest.entry_module, "demo");
268 assert_eq!(manifest.entry_function, "run");
269 assert_eq!(manifest.timeout, Duration::from_secs(30));
270 assert_eq!(manifest.input_schema, json!({ "type": "object" }));
271 assert_eq!(manifest.output_schema, json!(true));
272 assert_eq!(manifest.activities.len(), 1);
273 assert_eq!(manifest.activities[0].activity_type, "greet");
274 assert_eq!(
275 manifest.version.as_str(),
276 packaged.package.content_hash().to_string()
277 );
278 assert_eq!(packaged.version, packaged.package.version_record());
279 let reloaded = reloaded.ok_or("report failed")??;
280 assert_eq!(&reloaded, &packaged.package);
281 Ok(())
282 }
283
284 #[test]
285 fn exclusions_and_sources_are_reported_and_shipped() -> TestResult {
286 let root = fixture::synthetic_built_project("assemble-exclusions")?;
287 let report = package_project(&root, &PackageOptions::default());
288 fs::remove_dir_all(&root)?;
289 let report = report?;
290
291 let expected_exclusions = vec![
292 ExcludedModule {
293 module: "dev_only".to_owned(),
294 package: "dev_only".to_owned(),
295 reason: ExcludedReason::DevDependency,
296 },
297 ExcludedModule {
298 module: "aion@testing".to_owned(),
299 package: "aion_flow".to_owned(),
300 reason: ExcludedReason::SdkTestOnly,
301 },
302 ExcludedModule {
303 module: "aion@testing@mock".to_owned(),
304 package: "aion_flow".to_owned(),
305 reason: ExcludedReason::SdkTestOnly,
306 },
307 ExcludedModule {
308 module: "aion_flow_ffi".to_owned(),
309 package: "aion_flow".to_owned(),
310 reason: ExcludedReason::SdkTestOnly,
311 },
312 ];
313 assert_eq!(report.excluded, expected_exclusions);
314
315 let package = &report.packages[0].package;
316 let source_names: Vec<&str> = package.source().keys().map(String::as_str).collect();
317 assert_eq!(source_names, vec!["demo", "demo/nested"]);
318 let beam_names: Vec<&str> = package
319 .beams()
320 .iter()
321 .map(crate::BeamModule::name)
322 .collect();
323 assert_eq!(
324 beam_names,
325 vec!["aion_flow", "demo", "demo@nested", "dep_a", "dep_b"]
326 );
327 Ok(())
328 }
329
330 #[test]
331 fn missing_entry_module_returns_entry_module_not_found() -> TestResult {
332 let root = fixture::synthetic_built_project("assemble-ghost-entry")?;
333 let descriptor = fixture::DEMO_WORKFLOW_TOML.replace("\"demo\"", "\"ghost\"");
334 fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
335 let result = package_project(&root, &PackageOptions::default());
336 fs::remove_dir_all(&root)?;
337
338 assert!(matches!(
339 result,
340 Err(PackagingError::EntryModuleNotFound { module, searched })
341 if module == "ghost" && searched.ends_with("build/dev/erlang")
342 ));
343 Ok(())
344 }
345
346 #[test]
347 fn explicit_output_field_is_respected() -> TestResult {
348 let root = fixture::synthetic_built_project("assemble-explicit-output")?;
349 let descriptor = format!(
350 "{}output = \"custom-name.aion\"\n",
351 fixture::DEMO_WORKFLOW_TOML
352 );
353 fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
354 let report = package_project(&root, &PackageOptions::default());
355 let written = root.join("custom-name.aion").is_file();
356 fs::remove_dir_all(&root)?;
357 let report = report?;
358
359 assert!(written);
360 assert_eq!(
361 report.packages[0]
362 .output_path
363 .file_name()
364 .and_then(|name| name.to_str()),
365 Some("custom-name.aion")
366 );
367 Ok(())
368 }
369
370 #[test]
371 fn output_override_applies_to_single_workflow_project() -> TestResult {
372 let root = fixture::synthetic_built_project("assemble-override")?;
373 let options = PackageOptions {
374 output_override: Some(PathBuf::from("override.aion")),
375 };
376 let report = package_project(&root, &options);
377 let written = root.join("override.aion").is_file();
378 let derived_absent = !root.join("demo.aion").exists();
379 fs::remove_dir_all(&root)?;
380 let report = report?;
381
382 assert!(written);
383 assert!(derived_absent);
384 assert_eq!(
385 report.packages[0]
386 .output_path
387 .file_name()
388 .and_then(|name| name.to_str()),
389 Some("override.aion")
390 );
391 Ok(())
392 }
393
394 #[test]
395 fn output_write_failure_names_the_output_path() -> TestResult {
396 let root = fixture::synthetic_built_project("assemble-missing-dir")?;
397 let descriptor = format!(
398 "{}output = \"missing-dir/demo.aion\"\n",
399 fixture::DEMO_WORKFLOW_TOML
400 );
401 fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
402 let result = package_project(&root, &PackageOptions::default());
403 fs::remove_dir_all(&root)?;
404
405 let expected = root.join("missing-dir/demo.aion");
406 let Err(error) = result else {
407 return Err("write into a missing directory unexpectedly succeeded".into());
408 };
409 assert!(
410 matches!(
411 &error,
412 PackagingError::OutputWrite { path, .. } if *path == expected
413 ),
414 "error does not carry the output path: {error:?}"
415 );
416 assert!(
417 error.to_string().contains(&expected.display().to_string()),
418 "message does not name the output path: {error}"
419 );
420 Ok(())
421 }
422
423 #[test]
424 fn output_override_may_point_outside_root_via_dotdot() -> TestResult {
425 let root = fixture::synthetic_built_project("assemble-override-outside")?;
428 let outside_name = format!("aion-override-outside-{}.aion", std::process::id());
429 let options = PackageOptions {
430 output_override: Some(PathBuf::from(format!("../{outside_name}"))),
431 };
432 let report = package_project(&root, &options);
433 let outside = std::env::temp_dir().join(&outside_name);
434 let written = outside.is_file();
435 fs::remove_dir_all(&root)?;
436 if written {
437 fs::remove_file(&outside)?;
438 }
439 let report = report?;
440
441 assert!(written, "override outside the root was not written");
442 assert_eq!(
443 report.packages[0]
444 .output_path
445 .file_name()
446 .and_then(|name| name.to_str()),
447 Some(outside_name.as_str())
448 );
449 Ok(())
450 }
451
452 #[test]
453 fn output_override_with_multiple_workflows_is_ambiguous() -> TestResult {
454 let root = fixture::synthetic_built_project("assemble-override-multi")?;
455 fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
456 let options = PackageOptions {
457 output_override: Some(PathBuf::from("override.aion")),
458 };
459 let result = package_project(&root, &options);
460 fs::remove_dir_all(&root)?;
461
462 assert!(matches!(
463 result,
464 Err(PackagingError::OutputOverrideAmbiguous { count: 2 })
465 ));
466 Ok(())
467 }
468
469 #[test]
470 fn multi_workflow_project_shares_hash_with_distinct_deployed_entries() -> TestResult {
471 let root = fixture::synthetic_built_project("assemble-multi")?;
472 fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
473 let report = package_project(&root, &PackageOptions::default());
474 fs::remove_dir_all(&root)?;
475 let report = report?;
476
477 assert_eq!(report.packages.len(), 2);
478 let first = &report.packages[0];
479 let second = &report.packages[1];
480 assert_eq!(first.workflow_type, "demo");
481 assert_eq!(second.workflow_type, "demo@nested");
482 assert_eq!(first.package.content_hash(), second.package.content_hash());
483 assert_ne!(
484 first.package.deployed_entry_module(),
485 second.package.deployed_entry_module()
486 );
487 assert_ne!(first.output_path, second.output_path);
488 Ok(())
489 }
490
491 #[test]
492 fn user_module_with_reserved_name_fails_typed() -> TestResult {
493 let root = fixture::synthetic_built_project("assemble-reserved")?;
494 fixture::write_file(
495 &root,
496 "build/dev/erlang/demo/ebin/aion_flow_ffi.beam",
497 b"user-owned-bytes",
498 )?;
499 let result = package_project(&root, &PackageOptions::default());
500 fs::remove_dir_all(&root)?;
501
502 assert!(matches!(
503 result,
504 Err(PackagingError::Package(PackageError::ReservedModuleName { module }))
505 if module == "aion_flow_ffi"
506 ));
507 Ok(())
508 }
509
510 #[test]
511 fn repackaging_produces_identical_archive_bytes() -> TestResult {
512 let root = fixture::synthetic_built_project("assemble-det-1")?;
513 let first_report = package_project(&root, &PackageOptions::default());
514 let first_bytes = fs::read(root.join("demo.aion"));
515 let second_report = package_project(&root, &PackageOptions::default());
516 let second_bytes = fs::read(root.join("demo.aion"));
517 fs::remove_dir_all(&root)?;
518 first_report?;
519 second_report?;
520
521 let first_bytes = first_bytes?;
522 assert!(!first_bytes.is_empty());
523 assert_eq!(first_bytes, second_bytes?);
524 Ok(())
525 }
526
527 #[test]
528 fn source_inclusion_changes_bytes_but_never_the_version() -> TestResult {
529 let root = fixture::synthetic_built_project("assemble-det-3")?;
530 let with_source = package_project(&root, &PackageOptions::default());
531 let with_source_bytes = fs::read(root.join("demo.aion"));
532 let descriptor = format!(
533 "[package]\ninclude_source = false\n\n{}",
534 fixture::DEMO_WORKFLOW_TOML
535 );
536 fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
537 let without_source = package_project(&root, &PackageOptions::default());
538 let without_source_bytes = fs::read(root.join("demo.aion"));
539 fs::remove_dir_all(&root)?;
540 let with_source = with_source?;
541 let without_source = without_source?;
542
543 assert!(!with_source.packages[0].package.source().is_empty());
544 assert!(without_source.packages[0].package.source().is_empty());
545 assert_ne!(with_source_bytes?, without_source_bytes?);
546 assert_eq!(
547 with_source.packages[0].package.content_hash(),
548 without_source.packages[0].package.content_hash()
549 );
550 assert_eq!(
551 with_source.packages[0].package.manifest().version,
552 without_source.packages[0].package.manifest().version
553 );
554 Ok(())
555 }
556}