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, Manifest, ManifestVersion, Package,
17 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)?;
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 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}