use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use aion_package::{
ActivityDeclaration, BoundaryType, CodegenMode, boundary_types_from_interface, emit_schemas,
generate_activities, generate_codecs, generate_test_scaffold, parse_declarations,
};
use aion_toolchain::gleam_failure::{GleamFailureLayer, classify_gleam_failure};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::Value;
use toml_edit::DocumentMut;
use crate::output::to_value;
const MANIFEST_MARKER: &str = "AION_MANIFEST_BEGIN";
const PROBE_MODULE: &str = "aion_generate_probe";
const ASIDE_SUFFIX: &str = ".aiongen-aside";
const PROBE_DOC: &str =
"//// Temporary manifest-extraction probe written by `aion generate`. Safe to delete.";
const LOCK_FILE: &str = ".aiongen.lock";
const INTERFACE_OUT: &str = "build/.aiongen-interface.json";
#[derive(Serialize)]
struct GenerateOutput {
types_module: String,
codecs_module: String,
schemas_emitted: Vec<String>,
schemas_removed: Vec<String>,
activity_modules: Vec<String>,
test_scaffold: Option<String>,
declarations: usize,
workflow_toml: &'static str,
action: &'static str,
}
struct ActivityPhase {
activity_modules: Vec<String>,
test_scaffold: Option<String>,
declarations: usize,
workflow_toml: &'static str,
}
pub(crate) fn run(path: &Path, check: bool) -> Result<Value> {
let mode = if check {
CodegenMode::Check
} else {
CodegenMode::Write
};
let package_name = read_gleam_package_name(path)?;
let interface_json = export_package_interface(path, &package_name)?;
let types =
boundary_types_from_interface(&interface_json, &package_name).with_context(|| {
format!("failed to read the boundary types of src/{package_name}_io.gleam")
})?;
let codecs = generate_codecs(path, &types, mode).with_context(|| {
format!(
"failed to generate the codecs module for {}",
path.display()
)
})?;
let schemas = emit_schemas(path, &package_name, &types, mode)
.with_context(|| format!("failed to emit the schema artifacts for {}", path.display()))?;
let activity = activity_phase(path, &package_name, &types, mode)?;
to_value(GenerateOutput {
types_module: format!("src/{package_name}_io.gleam"),
codecs_module: codecs.module_relative,
schemas_emitted: schemas.emitted,
schemas_removed: schemas.removed,
activity_modules: activity.activity_modules,
test_scaffold: activity.test_scaffold,
declarations: activity.declarations,
workflow_toml: activity.workflow_toml,
action: if check { "checked" } else { "written" },
})
}
fn activity_phase(
path: &Path,
package_name: &str,
types: &[BoundaryType],
mode: CodegenMode,
) -> Result<ActivityPhase> {
let activities_file = path
.join("src")
.join(format!("{package_name}_activities.gleam"));
if !activities_file.is_file() {
return Ok(ActivityPhase {
activity_modules: Vec::new(),
test_scaffold: None,
declarations: 0,
workflow_toml: "skipped",
});
}
let manifest_json = extract_declarations(path, package_name)?;
let declarations = parse_declarations(&manifest_json).with_context(|| {
format!(
"failed to parse the activity manifest emitted by {package_name}_activities.manifest()"
)
})?;
let activities = generate_activities(path, &declarations, types, mode).with_context(|| {
format!(
"failed to generate the activity plumbing for {}",
path.display()
)
})?;
let workflow_toml = sync_workflow_activities(path, &declarations, mode)?;
let entry_module = read_entry_module(path)?;
let scaffold = generate_test_scaffold(path, &entry_module, &declarations, types, mode)
.with_context(|| {
format!(
"failed to generate the test scaffold for {}",
path.display()
)
})?;
Ok(ActivityPhase {
activity_modules: activities
.artifacts
.iter()
.map(|artifact| artifact.relative.clone())
.collect(),
test_scaffold: Some(scaffold.module_relative),
declarations: declarations.len(),
workflow_toml,
})
}
fn read_entry_module(root: &Path) -> Result<String> {
let toml_path = root.join("workflow.toml");
let contents = fs::read_to_string(&toml_path)
.with_context(|| format!("failed to read {}", toml_path.display()))?;
let document = contents
.parse::<DocumentMut>()
.with_context(|| format!("{} is not valid TOML", toml_path.display()))?;
let workflows = document
.get("workflow")
.and_then(|item| item.as_array_of_tables())
.with_context(|| {
format!(
"{} declares no [[workflow]] entry to scaffold a test for",
toml_path.display()
)
})?;
if workflows.len() > 1 {
bail!(
"{} declares {} [[workflow]] entries; `aion generate` scaffolds a test for a \
single-workflow package only",
toml_path.display(),
workflows.len()
);
}
let entry = workflows
.iter()
.next()
.and_then(|table| table.get("entry_module"))
.and_then(|item| item.as_str())
.with_context(|| {
format!(
"{} [[workflow]] entry has no `entry_module` to scaffold a test for",
toml_path.display()
)
})?;
Ok(entry.to_owned())
}
fn read_gleam_package_name(root: &Path) -> Result<String> {
let gleam_toml = root.join("gleam.toml");
let contents = fs::read_to_string(&gleam_toml)
.with_context(|| format!("failed to read {}", gleam_toml.display()))?;
let document = contents
.parse::<DocumentMut>()
.with_context(|| format!("{} is not valid TOML", gleam_toml.display()))?;
document
.get("name")
.and_then(|name| name.as_str())
.map(str::to_owned)
.with_context(|| format!("{} has no `name` field", gleam_toml.display()))
}
fn export_package_interface(root: &Path, package_name: &str) -> Result<Vec<u8>> {
let types_file = root.join("src").join(format!("{package_name}_io.gleam"));
if !types_file.is_file() {
bail!(
"expected {} to exist and declare the workflow's boundary types; it is the authored \
source of truth `aion generate` derives every codec and schema from (ADR-014)",
types_file.display()
);
}
let codecs_module = format!("{package_name}_codecs");
let wrappers_module = format!("{package_name}_activity_wrappers");
let golden_module = format!("{package_name}_wire_compat_test");
let (_lock, _scratch) = begin_toolchain_pass(
root,
&[&codecs_module, &wrappers_module, &golden_module],
None,
)?;
let out_path = std::path::absolute(root.join(INTERFACE_OUT))
.with_context(|| format!("failed to resolve {INTERFACE_OUT} under {}", root.display()))?;
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let output = Command::new("gleam")
.args(["export", "package-interface", "--out"])
.arg(&out_path)
.current_dir(root)
.output()
.with_context(|| {
format!(
"failed to run `gleam export package-interface` in {} (is a gleam toolchain \
with `export package-interface` on PATH?)",
root.display()
)
})?;
if !output.status.success() {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if classify_gleam_failure(&combined) == GleamFailureLayer::DependencyLayer {
bail!(
"`gleam export package-interface` could not reach its package registry in {}, so \
your workflow was never compiled — this is a network or registry failure, NOT a \
problem with your code. Nothing has been generated; try again when the registry \
is reachable.\n{combined}",
root.display()
);
}
bail!(
"interface export failed: `gleam export package-interface` exited with {} in {}. \
`gleam`'s own output follows; the most common cause is that the types module \
src/{package_name}_io.gleam (or another module not reaching the generated \
codecs/wrappers) does not compile standalone.\n{combined}",
output.status,
root.display()
);
}
let json = fs::read(&out_path)
.with_context(|| format!("failed to read the exported {}", out_path.display()))?;
let _ = fs::remove_file(&out_path);
Ok(json)
}
fn extract_declarations(root: &Path, package_name: &str) -> Result<Vec<u8>> {
let activities_module = format!("{package_name}_activities");
let wrappers_module = format!("{package_name}_activity_wrappers");
let golden_module = format!("{package_name}_wire_compat_test");
let (_lock, _scratch) = begin_toolchain_pass(
root,
&[&wrappers_module, &golden_module],
Some(&activities_module),
)?;
let output = Command::new("gleam")
.args(["run", "-m", PROBE_MODULE])
.current_dir(root)
.output()
.with_context(|| {
format!(
"failed to run `gleam run -m {PROBE_MODULE}` in {}",
root.display()
)
})?;
if !output.status.success() {
bail!(
"manifest extraction failed: `gleam run -m {PROBE_MODULE}` exited with {} in {}\n{}",
output.status,
root.display(),
String::from_utf8_lossy(&output.stderr)
);
}
extract_marked_json(&output.stdout)
}
fn begin_toolchain_pass(
root: &Path,
aside_targets: &[&str],
probe_activities_module: Option<&str>,
) -> Result<(ExtractionLock, ExtractionScratch)> {
let src = root.join("src");
recover_leftover_scratch(&src)?;
let lock = ExtractionLock::acquire(root)?;
let blocking = modules_reaching(root, aside_targets)?;
let mut scratch = ExtractionScratch::default();
for module_file in blocking {
scratch.rename_aside(&module_file)?;
}
if let Some(activities_module) = probe_activities_module {
scratch.write_probe(&src, activities_module)?;
}
Ok((lock, scratch))
}
fn modules_reaching(root: &Path, targets: &[&str]) -> Result<Vec<PathBuf>> {
let mut imports: HashMap<String, (PathBuf, Vec<String>)> = HashMap::new();
let src = root.join("src");
collect_modules(&src, &src, &mut imports)?;
let test = root.join("test");
if test.is_dir() {
collect_modules(&test, &test, &mut imports)?;
}
let mut blocking = Vec::new();
for (module, (file, _)) in &imports {
let is_target = targets.iter().any(|target| target == module);
let reaches_target = targets
.iter()
.any(|target| reaches(module, target, &imports, &mut Vec::new()));
if is_target || reaches_target {
blocking.push(file.clone());
}
}
blocking.sort();
Ok(blocking)
}
fn collect_modules(
src_root: &Path,
dir: &Path,
imports: &mut HashMap<String, (PathBuf, Vec<String>)>,
) -> Result<()> {
let entries = fs::read_dir(dir)
.with_context(|| format!("failed to list source directory {}", dir.display()))?;
for entry in entries {
let entry =
entry.with_context(|| format!("failed to read an entry in {}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
collect_modules(src_root, &path, imports)?;
} else if path.extension().is_some_and(|ext| ext == "gleam") {
let module = module_name(src_root, &path);
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
imports.insert(module, (path, parse_imports(&contents)));
}
}
Ok(())
}
fn module_name(src_root: &Path, file: &Path) -> String {
let relative = file.strip_prefix(src_root).unwrap_or(file);
let without_ext = relative.with_extension("");
without_ext
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn parse_imports(contents: &str) -> Vec<String> {
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
return None;
}
let rest = trimmed.strip_prefix("import ")?;
let token = rest
.split_whitespace()
.next()?
.split('.')
.next()
.unwrap_or_default();
(!token.is_empty()).then(|| token.to_owned())
})
.collect()
}
fn reaches(
module: &str,
target: &str,
imports: &HashMap<String, (PathBuf, Vec<String>)>,
stack: &mut Vec<String>,
) -> bool {
let Some((_, module_imports)) = imports.get(module) else {
return false;
};
if module_imports.iter().any(|import| import == target) {
return true;
}
if stack.iter().any(|seen| seen == module) {
return false;
}
stack.push(module.to_owned());
let found = module_imports
.iter()
.any(|import| reaches(import, target, imports, stack));
stack.pop();
found
}
fn extract_marked_json(stdout: &[u8]) -> Result<Vec<u8>> {
let text = String::from_utf8_lossy(stdout);
let mut lines = text.lines();
while let Some(line) = lines.next() {
if line.trim() == MANIFEST_MARKER {
let json = lines.next().with_context(|| {
"manifest probe printed its marker but no JSON line followed".to_owned()
})?;
return Ok(json.trim().as_bytes().to_vec());
}
}
bail!("manifest probe did not print the `{MANIFEST_MARKER}` marker; stdout was:\n{text}")
}
fn probe_source(activities_module: &str) -> String {
format!(
"{PROBE_DOC}\n\n\
import aion/manifest\n\
import gleam/io\n\
import {activities_module} as activities\n\n\
pub fn main() {{\n \
io.println(\"{MANIFEST_MARKER}\")\n \
io.println(manifest.to_json(activities.manifest()))\n}}\n"
)
}
fn recover_leftover_scratch(src: &Path) -> Result<()> {
recover_asides(src)?;
let probe = src.join(format!("{PROBE_MODULE}.gleam"));
match fs::read_to_string(&probe) {
Ok(contents) if contents.starts_with(PROBE_DOC) => fs::remove_file(&probe)
.with_context(|| format!("failed to remove a stale probe {}", probe.display()))?,
_ => {}
}
Ok(())
}
fn recover_asides(dir: &Path) -> Result<()> {
for entry in fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))? {
let path = entry
.with_context(|| format!("failed to read an entry in {}", dir.display()))?
.path();
if path.is_dir() {
recover_asides(&path)?;
} else if let Some(original) = aside_original(&path) {
if original.exists() {
fs::remove_file(&path)
.with_context(|| format!("failed to remove stale aside {}", path.display()))?;
} else {
fs::rename(&path, &original).with_context(|| {
format!(
"failed to restore {} from a crashed extraction",
original.display()
)
})?;
}
}
}
Ok(())
}
fn aside_original(path: &Path) -> Option<PathBuf> {
let name = path.to_str()?;
name.strip_suffix(ASIDE_SUFFIX).map(PathBuf::from)
}
struct ExtractionLock {
path: PathBuf,
}
impl ExtractionLock {
fn acquire(root: &Path) -> Result<Self> {
let path = root.join(LOCK_FILE);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(_) => Ok(Self { path }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => bail!(
"another `aion generate` is already extracting declarations for this project \
(lock file {} exists). Wait for it to finish; if you are sure no other run is \
in progress, delete {} and retry.",
path.display(),
path.display()
),
Err(error) => Err(error).with_context(|| {
format!(
"failed to acquire the manifest-extraction lock {}",
path.display()
)
}),
}
}
}
impl Drop for ExtractionLock {
fn drop(&mut self) {
if let Err(error) = fs::remove_file(&self.path)
&& self.path.exists()
{
eprintln!(
"warning: failed to remove the manifest-extraction lock {}: {error}; \
delete it manually before the next `aion generate`",
self.path.display()
);
}
}
}
#[derive(Default)]
struct ExtractionScratch {
probe: Option<PathBuf>,
renamed: Vec<(PathBuf, PathBuf)>,
}
impl ExtractionScratch {
fn rename_aside(&mut self, original: &Path) -> Result<()> {
let mut aside_name: OsString = original.as_os_str().to_owned();
aside_name.push(ASIDE_SUFFIX);
let aside = PathBuf::from(aside_name);
fs::rename(original, &aside).with_context(|| {
format!(
"failed to set aside {} for manifest extraction",
original.display()
)
})?;
self.renamed.push((aside, original.to_path_buf()));
Ok(())
}
fn write_probe(&mut self, src: &Path, activities_module: &str) -> Result<()> {
let probe = src.join(format!("{PROBE_MODULE}.gleam"));
fs::write(&probe, probe_source(activities_module))
.with_context(|| format!("failed to write the probe module {}", probe.display()))?;
self.probe = Some(probe);
Ok(())
}
}
impl Drop for ExtractionScratch {
fn drop(&mut self) {
if let Some(probe) = self.probe.take()
&& let Err(error) = fs::remove_file(&probe)
&& probe.exists()
{
eprintln!(
"warning: failed to remove manifest probe {}: {error}",
probe.display()
);
}
for (aside, original) in self.renamed.drain(..) {
if let Err(error) = fs::rename(&aside, &original) {
eprintln!(
"error: failed to restore {} from {} after manifest extraction: {error}; \
rename it back manually",
original.display(),
aside.display()
);
}
}
}
}
fn sync_workflow_activities(
root: &Path,
declarations: &[ActivityDeclaration],
mode: CodegenMode,
) -> Result<&'static str> {
let toml_path = root.join("workflow.toml");
let original = fs::read_to_string(&toml_path)
.with_context(|| format!("failed to read {}", toml_path.display()))?;
let mut document = original
.parse::<DocumentMut>()
.with_context(|| format!("{} is not valid TOML", toml_path.display()))?;
let desired: Vec<&str> = declarations
.iter()
.map(|declaration| declaration.name.as_str())
.collect();
let workflows = document
.get_mut("workflow")
.and_then(|item| item.as_array_of_tables_mut())
.with_context(|| {
format!(
"{} declares no [[workflow]] entry to attach activities to",
toml_path.display()
)
})?;
if workflows.len() > 1 {
bail!(
"{} declares {} [[workflow]] entries; `aion generate` syncs the activities \
list for a single-workflow package only",
toml_path.display(),
workflows.len()
);
}
let mut changed = false;
for table in workflows.iter_mut() {
if workflow_activities(table) == desired {
continue;
}
if mode == CodegenMode::Check {
bail!(
"--check failed: {} activities list is out of date; run `aion generate`",
toml_path.display()
);
}
let mut array = toml_edit::Array::new();
for name in &desired {
array.push(*name);
}
table["activities"] = toml_edit::value(array);
changed = true;
}
if mode == CodegenMode::Check {
return Ok("checked");
}
if changed {
fs::write(&toml_path, document.to_string())
.with_context(|| format!("failed to write {}", toml_path.display()))?;
Ok("synced")
} else {
Ok("unchanged")
}
}
fn workflow_activities(table: &toml_edit::Table) -> Vec<String> {
table
.get("activities")
.and_then(|item| item.as_array())
.map(|array| {
array
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{
ExtractionLock, LOCK_FILE, extract_marked_json, module_name, parse_imports, reaches,
sync_workflow_activities,
};
use aion_package::{ActivityDeclaration, CodegenMode, Tier};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[test]
fn module_names_use_forward_slashes_under_src() {
let src = Path::new("/p/src");
assert_eq!(
module_name(src, Path::new("/p/src/order_saga.gleam")),
"order_saga"
);
assert_eq!(
module_name(src, Path::new("/p/src/order_saga/locals.gleam")),
"order_saga/locals"
);
}
#[test]
fn imports_are_parsed_to_bare_module_names() {
let source = "import aion/manifest\nimport gleam/io.{println}\n\
import order_saga_codecs as codecs\nlet x = 1\n import nested/mod\n";
assert_eq!(
parse_imports(source),
vec![
"aion/manifest".to_owned(),
"gleam/io".to_owned(),
"order_saga_codecs".to_owned(),
"nested/mod".to_owned(),
]
);
}
#[test]
fn comment_lines_are_never_treated_as_imports() {
let source = "// import should_not_count\n\
//// import also/not/counted\n \
// import indented_comment\n";
assert!(parse_imports(source).is_empty());
}
#[test]
fn indented_real_import_is_found() {
assert_eq!(
parse_imports(" import real/mod\n"),
vec!["real/mod".to_owned()]
);
}
#[test]
fn aliased_and_unqualified_imports_yield_bare_name() {
assert_eq!(
parse_imports("import foo.{X} as y\n"),
vec!["foo".to_owned()]
);
assert_eq!(parse_imports("import a/b/c\n"), vec!["a/b/c".to_owned()]);
assert_eq!(parse_imports("import foo as bar\n"), vec!["foo".to_owned()]);
}
#[test]
fn trailing_comment_after_import_is_ignored_but_module_kept() {
assert_eq!(
parse_imports("import foo // note\n"),
vec!["foo".to_owned()]
);
assert_eq!(
parse_imports("import a/b/c // a trailing import comment\n"),
vec!["a/b/c".to_owned()]
);
}
#[test]
fn transitive_importers_of_the_target_are_found() {
let mut imports: HashMap<String, (PathBuf, Vec<String>)> = HashMap::new();
imports.insert(
"workflow".to_owned(),
(PathBuf::from("workflow.gleam"), vec!["wrappers".to_owned()]),
);
imports.insert(
"mid".to_owned(),
(PathBuf::from("mid.gleam"), vec!["workflow".to_owned()]),
);
imports.insert(
"activities".to_owned(),
(PathBuf::from("activities.gleam"), vec!["codecs".to_owned()]),
);
assert!(reaches("workflow", "wrappers", &imports, &mut Vec::new()));
assert!(reaches("mid", "wrappers", &imports, &mut Vec::new()));
assert!(!reaches(
"activities",
"wrappers",
&imports,
&mut Vec::new()
));
}
#[test]
fn cyclic_imports_do_not_loop() {
let mut imports: HashMap<String, (PathBuf, Vec<String>)> = HashMap::new();
imports.insert(
"a".to_owned(),
(PathBuf::from("a.gleam"), vec!["b".to_owned()]),
);
imports.insert(
"b".to_owned(),
(PathBuf::from("b.gleam"), vec!["a".to_owned()]),
);
assert!(!reaches("a", "wrappers", &imports, &mut Vec::new()));
}
#[test]
fn marked_json_is_extracted_from_noisy_stdout() -> Result<(), Box<dyn std::error::Error>> {
let stdout =
b" Compiling demo\n Compiled in 0.3s\nAION_MANIFEST_BEGIN\n[{\"name\":\"a\"}]\n";
assert_eq!(extract_marked_json(stdout)?, b"[{\"name\":\"a\"}]");
Ok(())
}
#[test]
fn missing_marker_is_an_error() {
let stdout = b"some unrelated output\n";
assert!(extract_marked_json(stdout).is_err());
}
#[test]
fn modules_reaching_sets_aside_targets_and_their_importers()
-> Result<(), Box<dyn std::error::Error>> {
use super::modules_reaching;
let temp = tempfile::tempdir()?;
let src = temp.path().join("src");
std::fs::create_dir(&src)?;
std::fs::write(src.join("demo_io.gleam"), "pub type Input { Input }\n")?;
std::fs::write(src.join("demo_codecs.gleam"), "import demo_io\n")?;
std::fs::write(
src.join("demo_activity_wrappers.gleam"),
"import demo_activities\nimport demo_codecs\nimport demo_io\n",
)?;
std::fs::write(src.join("demo_activities.gleam"), "import demo_codecs\n")?;
std::fs::write(
src.join("workflow.gleam"),
"import demo_activity_wrappers\n",
)?;
std::fs::write(src.join("standalone.gleam"), "import demo_io\n")?;
let mut aside = modules_reaching(temp.path(), &["demo_codecs", "demo_activity_wrappers"])?;
aside.sort();
let names: Vec<String> = aside
.iter()
.filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(
names,
vec![
"demo_activities.gleam",
"demo_activity_wrappers.gleam",
"demo_codecs.gleam",
"workflow.gleam",
]
);
let aside = modules_reaching(temp.path(), &["demo_activity_wrappers"])?;
let names: Vec<String> = aside
.iter()
.filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(
names,
vec!["demo_activity_wrappers.gleam", "workflow.gleam"]
);
Ok(())
}
#[test]
fn crash_leftovers_are_recovered() -> Result<(), Box<dyn std::error::Error>> {
use super::{PROBE_MODULE, recover_leftover_scratch};
let temp = tempfile::tempdir()?;
let src = temp.path();
std::fs::create_dir(src.join("nested"))?;
std::fs::write(src.join("order_saga.gleam.aiongen-aside"), b"workflow")?;
std::fs::write(src.join("nested/helper.gleam"), b"current")?;
std::fs::write(src.join("nested/helper.gleam.aiongen-aside"), b"stale")?;
std::fs::write(
src.join(format!("{PROBE_MODULE}.gleam")),
super::probe_source("demo_activities"),
)?;
recover_leftover_scratch(src)?;
assert!(src.join("order_saga.gleam").is_file());
assert!(!src.join("order_saga.gleam.aiongen-aside").exists());
assert_eq!(std::fs::read(src.join("nested/helper.gleam"))?, b"current");
assert!(!src.join("nested/helper.gleam.aiongen-aside").exists());
assert!(!src.join(format!("{PROBE_MODULE}.gleam")).exists());
recover_leftover_scratch(src)?;
Ok(())
}
fn declaration(name: &str) -> ActivityDeclaration {
ActivityDeclaration {
name: name.to_owned(),
tier: Tier::InVm,
input_type: "Input".to_owned(),
output_type: "Output".to_owned(),
}
}
fn read_workflow_activities(path: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let document = std::fs::read_to_string(path)?.parse::<toml_edit::DocumentMut>()?;
let Some(array) = document
.get("workflow")
.and_then(|item| item.as_array_of_tables())
.and_then(|tables| tables.iter().next())
.and_then(|table| table.get("activities"))
.and_then(|item| item.as_array())
else {
return Err("workflow.toml has no [[workflow]] activities array".into());
};
Ok(array
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect())
}
#[test]
fn sync_clean_match_write_is_unchanged_and_preserves_bytes()
-> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
let original =
"# keep me\n[[workflow]]\nname = \"saga\"\nactivities = [\"charge\", \"ship\"]\n";
std::fs::write(&toml, original)?;
let declarations = [declaration("charge"), declaration("ship")];
let action = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Write)?;
assert_eq!(action, "unchanged");
assert_eq!(std::fs::read_to_string(&toml)?, original);
Ok(())
}
#[test]
fn sync_clean_match_check_is_checked() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
std::fs::write(&toml, "[[workflow]]\nactivities = [\"charge\", \"ship\"]\n")?;
let declarations = [declaration("charge"), declaration("ship")];
let action = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Check)?;
assert_eq!(action, "checked");
Ok(())
}
#[test]
fn sync_stale_list_write_rewrites_in_declared_order() -> Result<(), Box<dyn std::error::Error>>
{
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
std::fs::write(&toml, "[[workflow]]\nactivities = [\"ship\"]\n")?;
let declarations = [
declaration("charge"),
declaration("ship"),
declaration("refund"),
];
let action = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Write)?;
assert_eq!(action, "synced");
assert_eq!(
read_workflow_activities(&toml)?,
vec!["charge".to_owned(), "ship".to_owned(), "refund".to_owned()]
);
Ok(())
}
#[test]
fn sync_stale_list_check_bails_with_path_and_reason() -> Result<(), Box<dyn std::error::Error>>
{
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
std::fs::write(&toml, "[[workflow]]\nactivities = [\"ship\"]\n")?;
let declarations = [declaration("charge"), declaration("ship")];
let result = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Check);
let Err(error) = result else {
return Err("a stale list under --check must be an error".into());
};
let message = format!("{error}");
assert!(
message.contains("workflow.toml") && message.contains("out of date"),
"error must name the toml and say it is out of date: {message}"
);
Ok(())
}
#[test]
fn sync_multiple_workflow_tables_bails_naming_the_count()
-> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
let two_tables = "[[workflow]]\nname = \"a\"\n[[workflow]]\nname = \"b\"\n";
std::fs::write(&toml, two_tables)?;
let declarations = [declaration("charge")];
let result = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Write);
let Err(error) = result else {
return Err("more than one [[workflow]] table must be an error".into());
};
let message = format!("{error}");
assert!(
message.contains('2'),
"error must name the [[workflow]] count: {message}"
);
Ok(())
}
#[test]
fn sync_no_workflow_table_bails() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
std::fs::write(&toml, "name = \"no-workflow-table\"\n")?;
let declarations = [declaration("charge")];
let result = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Write);
assert!(
result.is_err(),
"a workflow.toml with no [[workflow]] table must be an error"
);
Ok(())
}
#[test]
fn sync_missing_activities_key_is_created_on_write() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let toml = temp.path().join("workflow.toml");
std::fs::write(&toml, "[[workflow]]\nname = \"saga\"\n")?;
let declarations = [declaration("charge"), declaration("ship")];
let action = sync_workflow_activities(temp.path(), &declarations, CodegenMode::Write)?;
assert_eq!(action, "synced");
assert_eq!(
read_workflow_activities(&toml)?,
vec!["charge".to_owned(), "ship".to_owned()]
);
Ok(())
}
#[test]
fn extraction_lock_is_exclusive_then_reacquirable() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let root = temp.path();
let guard = ExtractionLock::acquire(root)?;
assert!(root.join(LOCK_FILE).is_file(), "lock file must exist");
assert!(
ExtractionLock::acquire(root).is_err(),
"a second concurrent lock acquisition must fail"
);
drop(guard);
assert!(
!root.join(LOCK_FILE).exists(),
"the lock file must be removed when the guard drops"
);
let _again = ExtractionLock::acquire(root)?;
assert!(
root.join(LOCK_FILE).is_file(),
"the lock must be re-acquirable once released"
);
Ok(())
}
}